refactor(stores): one keyset page helper for store listings

Thirteen store listings each cut a LIMIT n+1 read to a page and sealed the
last row's position, and each opened an id cursor its own way. They now use
keysetPage() and openIdCursor() from util/pagination. Cursor payloads and
sealing are unchanged; a cursor without a usable id still fails with 400 in
ShareStore and still restarts the listing elsewhere (strict option).
This commit is contained in:
Daniel Salazar committed 2026-10-10 19:53:48 -04:00
1 parent 8e933ee9a3
commit 1db1e1321d
9 files changed
+228 -209

No files matched your search

@@ -26,8 +26,8 @@ import { HttpError } from '../../core/http/HttpError.js';
import type { DeliveryClass } from '../../services/events/registry.js';
import type { FsOp } from '../../services/events/subjects.js';
import {
openCursor,
sealCursor,
keysetPage,
openIdCursor,
type PageResult,
} from '../../util/pagination.js';
import { PuterStore } from '../types.js';
@@ -587,9 +587,7 @@ export class DurableSubscriptionStore extends PuterStore {
),
DURABLE_LIST_LIMIT_CAP,
);
const after = asNumber(
openCursor(options.cursor, this.config.jwt_secret_v2)?.id,
);
const after = openIdCursor(options.cursor, this.config.jwt_secret_v2);
// The scope half of the predicate is what the total counts over; the
// cursor half only positions one page inside it.
@@ -613,15 +611,11 @@ export class DurableSubscriptionStore extends PuterStore {
[...params, limit + 1],
);
const page = rows.slice(0, limit);
const page = keysetPage(rows, limit, this.config.jwt_secret_v2);
const result: PageResult<DurableSubscription> = {
items: page.map(toRow),
items: page.rows.map(toRow),
};
if (rows.length > limit)
result.cursor = sealCursor(
{ id: Number(page[page.length - 1].id) },
this.config.jwt_secret_v2,
);
if (page.cursor) result.cursor = page.cursor;
if (options.includeTotal) {
const [count] = await this.clients.db.read(
@@ -25,7 +25,7 @@ import {
import { HttpError } from '../../core/http/HttpError.js';
import {
decodeCursor,
encodeCursor,
keysetPage,
type PageResult,
} from '../../util/pagination.js';
import { isUniqueViolation } from '../../util/dbError.js';
@@ -477,9 +477,12 @@ export class EventHandlerStore extends PuterStore {
[...params, limit + 1],
);
const page = rows.slice(0, limit);
// An app uid rather than a sequence position, so it needn't be sealed.
const page = keysetPage(rows, limit, undefined, (last) => ({
appUid: last.app_uid,
}));
const result: PageResult<EventsWorkerSummary> = {
items: page.map((row) => ({
items: page.rows.map((row) => ({
appUid: String(row.app_uid),
appName: String(row.app_name),
appTitle: String(row.app_title),
@@ -488,10 +491,7 @@ export class EventHandlerStore extends PuterStore {
updatedAt: Number(row.updated_at) || 0,
})),
};
if (rows.length > limit)
result.cursor = encodeCursor({
appUid: page[page.length - 1]!.app_uid,
});
if (page.cursor) result.cursor = page.cursor;
return result;
}
@@ -19,8 +19,8 @@
import { mintKvHandleId } from '../../services/events/kvShares.js';
import {
openCursor,
sealCursor,
keysetPage,
openIdCursor,
type PageResult,
} from '../../util/pagination.js';
import { PuterStore } from '../types.js';
@@ -78,11 +78,6 @@ export interface FindLiveKvShareHandleInput {
const nowSeconds = (): number => Math.floor(Date.now() / 1000);
const asNumber = (value: unknown): number | null => {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
};
const toRow = (row: Record<string, unknown>): KvShareHandle => ({
handle: String(row.handle),
ownerUserId: Number(row.owner_user_id),
@@ -254,9 +249,7 @@ export class KvShareHandleStore extends PuterStore {
),
KV_HANDLE_LIST_LIMIT_CAP,
);
const after = asNumber(
openCursor(options.cursor, this.config.jwt_secret_v2)?.id,
);
const after = openIdCursor(options.cursor, this.config.jwt_secret_v2);
const filter = ['`owner_user_id` = ?'];
const filterParams: unknown[] = [ownerUserId];
@@ -278,13 +271,11 @@ export class KvShareHandleStore extends PuterStore {
[...params, limit + 1],
);
const page = rows.slice(0, limit);
const result: PageResult<KvShareHandle> = { items: page.map(toRow) };
if (rows.length > limit)
result.cursor = sealCursor(
{ id: Number(page[page.length - 1].id) },
this.config.jwt_secret_v2,
);
const page = keysetPage(rows, limit, this.config.jwt_secret_v2);
const result: PageResult<KvShareHandle> = {
items: page.rows.map(toRow),
};
if (page.cursor) result.cursor = page.cursor;
if (options.includeTotal) {
const [count] = await this.clients.db.read(
+39 -47
View File
@@ -25,9 +25,9 @@ import { HttpError } from '../../core/http/HttpError.js';
import type { LayerInstances } from '../../types.js';
import { runWithConcurrencyLimit } from '../../util/concurrency.js';
import {
keysetPage,
normalizeLimit,
openCursor,
sealCursor,
} from '../../util/pagination.js';
import type { puterStores } from '../index.js';
import { PuterStore } from '../types.js';
@@ -2679,33 +2679,31 @@ export class FSEntryStore extends PuterStore {
params,
)) as unknown as FSEntryRow[];
const hasMore = rows.length > limit;
const pageRows = hasMore ? rows.slice(0, limit) : rows;
const entries = await this.#finalizeChildEntries(pageRows);
let cursor: string | undefined;
if (hasMore) {
const last = pageRows[pageRows.length - 1]!;
const v = (() => {
switch (sortBy) {
case 'modified':
return last.modified ?? 0;
case 'size':
return last.size ?? -1;
case 'type':
return last.is_dir;
case 'name':
default:
return last.name;
}
})();
cursor = sealCursor(
{ v, id: Number(last.id), s: sortBy, o: sortOrder },
this.config.jwt_secret_v2,
);
}
return { entries, ...(cursor ? { cursor } : {}) };
const page = keysetPage(
rows,
limit,
this.config.jwt_secret_v2,
(last) => ({
v: (() => {
switch (sortBy) {
case 'modified':
return last.modified ?? 0;
case 'size':
return last.size ?? -1;
case 'type':
return last.is_dir;
case 'name':
default:
return last.name;
}
})(),
id: Number(last.id),
s: sortBy,
o: sortOrder,
}),
);
const entries = await this.#finalizeChildEntries(page.rows);
return { entries, ...(page.cursor ? { cursor: page.cursor } : {}) };
}
async countChildren(parentUid: string): Promise<number> {
@@ -2882,25 +2880,19 @@ export class FSEntryStore extends PuterStore {
params,
)) as unknown as FSEntryRow[];
const hasMore = rows.length > limit;
const pageRows = hasMore ? rows.slice(0, limit) : rows;
const entries = await this.#finalizeChildEntries(pageRows);
let cursor: string | undefined;
if (hasMore) {
const last = pageRows[pageRows.length - 1]!;
cursor = sealCursor(
{
v: descendantSortValue(last, sortBy),
...(tiebreak ? { id: Number(last.id) } : {}),
s: sortBy,
o: sortOrder,
},
this.config.jwt_secret_v2,
);
}
return { entries, ...(cursor ? { cursor } : {}) };
const page = keysetPage(
rows,
limit,
this.config.jwt_secret_v2,
(last) => ({
v: descendantSortValue(last, sortBy),
...(tiebreak ? { id: Number(last.id) } : {}),
s: sortBy,
o: sortOrder,
}),
);
const entries = await this.#finalizeChildEntries(page.rows);
return { entries, ...(page.cursor ? { cursor: page.cursor } : {}) };
}
async countDescendantsToDepth(
@@ -28,7 +28,7 @@ import {
PERMISSION_SCAN_CACHE_TTL_SECONDS,
} from '../../services/permission/consts';
import { kv } from '../../util/kvSingleton';
import { openCursor, sealCursor } from '../../util/pagination';
import { keysetPage, openIdCursor } from '../../util/pagination';
import type { UserRow } from '../user/UserStore';
// Short TTLs: FK CASCADE on user/app delete + PermissionService rewriters
@@ -667,12 +667,10 @@ export class PermissionStore extends PuterStore {
Math.max(1, Math.floor(Number(opts.limit) || AUDIT_PAGE_SIZE)),
MAX_AUDIT_PAGE_SIZE,
);
const decoded = openCursor(
opts.cursor,
this.config.jwt_secret_v2,
'audit cursor',
);
const beforeId = Number(decoded?.id ?? 0) || null;
const beforeId =
openIdCursor(opts.cursor, this.config.jwt_secret_v2, {
label: 'audit cursor',
}) || null;
const { sql, params } = this.#auditWhere(filter);
const rows = await this.clients.db.read(
@@ -683,15 +681,10 @@ export class PermissionStore extends PuterStore {
[...params, ...(beforeId === null ? [] : [beforeId]), size + 1],
);
const hasMore = rows.length > size;
const items = rows.slice(0, size).map((row) => this.#auditRow(row));
const last = items[items.length - 1];
const page = keysetPage(rows, size, this.config.jwt_secret_v2);
return {
items,
cursor:
hasMore && last
? sealCursor({ id: last.id }, this.config.jwt_secret_v2)
: undefined,
items: page.rows.map((row) => this.#auditRow(row)),
cursor: page.cursor,
};
}
+23 -50
View File
@@ -19,12 +19,7 @@
import { v4 as uuidv4 } from 'uuid';
import { HttpError } from '../../core/http/HttpError.js';
import {
decodeCursor,
encodeCursor,
openCursor,
sealCursor,
} from '../../util/pagination';
import { decodeCursor, keysetPage, openIdCursor } from '../../util/pagination';
import { PuterStore } from '../types';
/** Default page size for the keyset listings. */
@@ -110,17 +105,10 @@ export class ShareStore extends PuterStore {
[...holderParams, afterId, size + 1],
);
const hasMore = rows.length > size;
const items = (hasMore ? rows.slice(0, size) : rows).map((r) =>
this.#normalizeRow(r),
);
const last = items[items.length - 1];
const page = keysetPage(rows, size, this.config.jwt_secret_v2);
return {
items,
cursor:
hasMore && last
? sealCursor({ id: last.id }, this.config.jwt_secret_v2)
: undefined,
items: page.rows.map((r) => this.#normalizeRow(r)),
cursor: page.cursor,
};
}
@@ -184,15 +172,10 @@ export class ShareStore extends PuterStore {
const merged = [...issued, ...delegated, ...unrecorded].sort(
(a, b) => Number(a.id) - Number(b.id),
);
const hasMore = merged.length > size;
const items = merged.slice(0, size).map((r) => this.#normalizeRow(r));
const last = items[items.length - 1];
const page = keysetPage(merged, size, this.config.jwt_secret_v2);
return {
items,
cursor:
hasMore && last
? sealCursor({ id: last.id }, this.config.jwt_secret_v2)
: undefined,
items: page.rows.map((r) => this.#normalizeRow(r)),
cursor: page.cursor,
};
}
@@ -277,18 +260,16 @@ export class ShareStore extends PuterStore {
],
);
const hasMore = rows.length > size;
const items = rows.slice(0, size).map((row) => ({
appUid: row.app_uid === '' ? null : String(row.app_uid),
count: Number(row.count),
// An app uid rather than a sequence position, so it needn't be sealed.
const page = keysetPage(rows, size, undefined, (last) => ({
appUid: String(last.app_uid),
}));
const last = items[items.length - 1];
return {
items,
cursor:
hasMore && last
? encodeCursor({ appUid: last.appUid ?? '' })
: undefined,
items: page.rows.map((row) => ({
appUid: row.app_uid === '' ? null : String(row.app_uid),
count: Number(row.count),
})),
cursor: page.cursor,
};
}
@@ -1252,25 +1233,17 @@ export class ShareStore extends PuterStore {
}
/**
* The id a keyset page resumes after; 0 for the first page. A cursor that
* decodes but names no usable id — another endpoint's cursor, say — is
* refused rather than read as page one, which would silently restart a
* client's iteration from the top.
* The id a keyset page resumes after; 0 for the first page. A cursor
* without a usable id is refused rather than read as page one, which would
* silently restart a client's iteration from the top.
*/
#afterId(cursor) {
const decoded = openCursor(
cursor,
this.config.jwt_secret_v2,
'share cursor',
return (
openIdCursor(cursor, this.config.jwt_secret_v2, {
label: 'share cursor',
strict: true,
}) ?? 0
);
if (decoded === undefined) return 0;
const id = Number(decoded.id);
if (!Number.isInteger(id) || id < 0) {
throw new HttpError(400, 'invalid share cursor', {
legacyCode: 'bad_request',
});
}
return id;
}
/**
+25 -61
View File
@@ -19,9 +19,9 @@
import { v4 as uuidv4 } from 'uuid';
import {
keysetPage,
normalizeLimit,
openCursor,
sealCursor,
openIdCursor,
type PageResult,
} from '../../util/pagination.js';
import { PuterStore } from '../types';
@@ -440,12 +440,9 @@ export class TeamStore extends PuterStore {
const limit =
normalizeLimit(opts.limit, { cap: MEMBER_PAGE_CAP }) ??
MEMBER_PAGE_SIZE;
const page = openCursor(
opts.cursor,
this.config.jwt_secret_v2,
'team member cursor',
);
const after = typeof page?.id === 'number' ? page.id : null;
const after = openIdCursor(opts.cursor, this.config.jwt_secret_v2, {
label: 'team member cursor',
});
// One row past the limit is how we know a further page exists.
const rows = (await this.clients.db.read(
@@ -462,15 +459,8 @@ export class TeamStore extends PuterStore {
: [teamUid, TEAM_KIND, after, limit + 1],
)) as unknown as TeamMemberRow[];
const items = rows.slice(0, limit);
const cursor =
rows.length > limit
? sealCursor(
{ id: items[items.length - 1].id },
this.config.jwt_secret_v2,
)
: undefined;
return { items, cursor };
const page = keysetPage(rows, limit, this.config.jwt_secret_v2);
return { items: page.rows, cursor: page.cursor };
}
/**
@@ -485,12 +475,9 @@ export class TeamStore extends PuterStore {
const limit =
normalizeLimit(opts.limit, { cap: MEMBER_PAGE_CAP }) ??
MEMBER_PAGE_SIZE;
const page = openCursor(
opts.cursor,
this.config.jwt_secret_v2,
'team directory cursor',
);
const after = typeof page?.id === 'number' ? page.id : null;
const after = openIdCursor(opts.cursor, this.config.jwt_secret_v2, {
label: 'team directory cursor',
});
const rows = (await this.clients.db.read(
'SELECT ug.`id`, u.`username`, u.`uuid` FROM `jct_user_group` ug ' +
@@ -505,15 +492,8 @@ export class TeamStore extends PuterStore {
: [teamUid, TEAM_KIND, after, limit + 1],
)) as unknown as { id: number; username: string; uuid: string }[];
const items = rows.slice(0, limit);
const cursor =
rows.length > limit
? sealCursor(
{ id: items[items.length - 1].id },
this.config.jwt_secret_v2,
)
: undefined;
return { items, cursor };
const page = keysetPage(rows, limit, this.config.jwt_secret_v2);
return { items: page.rows, cursor: page.cursor };
}
/** Internal ids of this user's live teams, for the share listing. */
@@ -633,12 +613,9 @@ export class TeamStore extends PuterStore {
const limit =
normalizeLimit(opts.limit, { cap: MEMBER_PAGE_CAP }) ??
MEMBER_PAGE_SIZE;
const page = openCursor(
opts.cursor,
this.config.jwt_secret_v2,
'team cursor',
);
const after = typeof page?.id === 'number' ? page.id : null;
const after = openIdCursor(opts.cursor, this.config.jwt_secret_v2, {
label: 'team cursor',
});
const open = opts.openOnly ? ' AND g.`directory_enabled` = 1' : '';
const rows = (await this.clients.db.read(
@@ -653,14 +630,11 @@ export class TeamStore extends PuterStore {
: [userId, TEAM_KIND, after, limit + 1],
)) as unknown as TeamRow[];
const items = rows.slice(0, limit);
const cursor =
rows.length > limit
? sealCursor(
{ id: items[items.length - 1].id },
this.config.jwt_secret_v2,
)
: undefined;
const { rows: items, cursor } = keysetPage(
rows,
limit,
this.config.jwt_secret_v2,
);
let total: number | undefined;
if (opts.includeTotal) {
@@ -903,12 +877,9 @@ export class TeamStore extends PuterStore {
const limit =
normalizeLimit(opts.limit, { cap: AUDIT_PAGE_CAP }) ??
AUDIT_PAGE_SIZE;
const page = openCursor(
opts.cursor,
this.config.jwt_secret_v2,
'team audit cursor',
);
const before = typeof page?.id === 'number' ? page.id : null;
const before = openIdCursor(opts.cursor, this.config.jwt_secret_v2, {
label: 'team audit cursor',
});
// Unix seconds in SQL: the mysql driver reads a stored UTC datetime
// as local, which shifts every row away from the sign-ins.
@@ -928,15 +899,8 @@ export class TeamStore extends PuterStore {
: [...params, before, limit + 1],
)) as unknown as TeamAuditRow[];
const items = rows.slice(0, limit);
const cursor =
rows.length > limit
? sealCursor(
{ id: items[items.length - 1].id },
this.config.jwt_secret_v2,
)
: undefined;
return { items, cursor };
const page = keysetPage(rows, limit, this.config.jwt_secret_v2);
return { items: page.rows, cursor: page.cursor };
}
/** Removes a member, returning whether a row was there to remove. */
+63
View File
@@ -2,9 +2,11 @@ import { describe, expect, it } from 'vitest';
import {
decodeCursor,
encodeCursor,
keysetPage,
normalizeLimit,
normalizeOffset,
openCursor,
openIdCursor,
sealCursor,
} from './pagination';
import { HttpError } from '../core/http';
@@ -139,4 +141,65 @@ describe('pagination util', () => {
expect(normalizeOffset(5000, { cap: 5000 })).toBe(5000);
});
});
describe('openIdCursor', () => {
const secret = 'keyset-secret';
it('returns null without a cursor', () => {
expect(openIdCursor(undefined, secret)).toBeNull();
expect(openIdCursor('', secret)).toBeNull();
});
it('reads the id from a sealed or plain cursor', () => {
expect(openIdCursor(sealCursor({ id: 41 }, secret), secret)).toBe(
41,
);
expect(openIdCursor(encodeCursor({ id: 7 }), secret)).toBe(7);
});
it('reads a cursor without a usable id as the first page', () => {
expect(
openIdCursor(encodeCursor({ appUid: 'app-1' }), secret),
).toBeNull();
expect(openIdCursor(encodeCursor({ id: -3 }), secret)).toBeNull();
});
it('refuses a cursor without a usable id when strict', () => {
expect(() =>
openIdCursor(encodeCursor({ id: 'x' }), secret, {
label: 'share cursor',
strict: true,
}),
).toThrowError('invalid share cursor');
});
it('refuses an unreadable cursor either way', () => {
expect(() => openIdCursor('!!!', secret)).toThrowError(HttpError);
});
});
describe('keysetPage', () => {
const secret = 'keyset-secret';
const rows = [{ id: 3 }, { id: 5 }, { id: 9 }];
it('returns every row and no cursor when nothing follows', () => {
expect(keysetPage(rows, 3, secret)).toEqual({ rows });
});
it('cuts to the limit and points the cursor at the last row kept', () => {
const page = keysetPage(rows, 2, secret);
expect(page.rows).toEqual([{ id: 3 }, { id: 5 }]);
expect(openIdCursor(page.cursor, secret)).toBe(5);
});
it('takes a custom position, plain without a secret', () => {
const page = keysetPage(
[{ app: 'a' }, { app: 'b' }],
1,
undefined,
(last) => ({ appUid: last.app }),
);
expect(decodeCursor(page.cursor)).toEqual({ appUid: 'a' });
});
});
});
+49
View File
@@ -190,3 +190,52 @@ export const normalizeOffset = (
}
return floored;
};
// -- Keyset pages -----------------------------------------------------
/**
* The id a keyset page resumes from, or null on the first page. A cursor that
* opens without a usable id (another listing's, say) throws when `strict`, and
* otherwise reads as the first page.
*/
export const openIdCursor = (
cursor: string | Record<string, unknown> | null | undefined,
secret: string | undefined,
{
label = 'cursor',
strict = false,
}: { label?: string; strict?: boolean } = {},
): number | null => {
const decoded = openCursor(cursor, secret, label);
if (decoded === undefined) return null;
const id = Number(decoded.id);
if (Number.isSafeInteger(id) && id >= 0) return id;
if (strict) {
throw new HttpError(400, `invalid ${label}`, {
legacyCode: 'bad_request',
});
}
return null;
};
/**
* One page of `rows`, fetched with `LIMIT limit + 1`: the first `limit` rows,
* and a cursor at the last of them when the extra row says another page
* follows. `position` is the cursor payload; it defaults to the row's `id`.
* Without `secret` the cursor is a plain `encodeCursor` one.
*/
export const keysetPage = <R>(
rows: R[],
limit: number,
secret: string | undefined,
position: (last: R) => Record<string, unknown> = (last) => ({
id: Number((last as { id?: unknown }).id),
}),
): { rows: R[]; cursor?: string } => {
if (rows.length <= limit) return { rows };
const page = rows.slice(0, limit);
return {
rows: page,
cursor: sealCursor(position(page[page.length - 1]!), secret),
};
};