Add paginationMiddleware, getting warmer

This commit is contained in:
Alex Gleason 2025-02-17 23:03:59 -06:00
parent 316e3e287f
commit e2ce9d0dab
No known key found for this signature in database
GPG key ID: 7211D1F99744FBB7
8 changed files with 126 additions and 65 deletions

View file

@ -4,7 +4,7 @@
"exports": { "exports": {
".": "./mod.ts", ".": "./mod.ts",
"./middleware": "./middleware/mod.ts", "./middleware": "./middleware/mod.ts",
"./pagination": "./pagination.ts", "./pagination": "./pagination/mod.ts",
"./routes": "./routes/mod.ts", "./routes": "./routes/mod.ts",
"./schema": "./schema.ts", "./schema": "./schema.ts",
"./views": "./views/mod.ts" "./views": "./views/mod.ts"

View file

@ -1,2 +1,3 @@
export { paginationMiddleware } from './paginationMiddleware.ts';
export { requireVar } from './requireVar.ts'; export { requireVar } from './requireVar.ts';
export { userMiddleware } from './userMiddleware.ts'; export { userMiddleware } from './userMiddleware.ts';

View file

@ -0,0 +1,80 @@
import { paginated, paginatedList, paginationSchema } from '@ditto/api/pagination';
import type { NostrEvent } from '@nostrify/nostrify';
import type { DittoMiddleware } from '../DittoMiddleware.ts';
interface Pagination {
since?: number;
until?: number;
limit: number;
}
interface ListPagination {
limit: number;
offset: number;
}
type HeaderRecord = Record<string, string | string[]>;
type PaginateFn = (events: NostrEvent[], body: object | unknown[], headers?: HeaderRecord) => Response;
type ListPaginateFn = (params: ListPagination, body: object | unknown[], headers?: HeaderRecord) => Response;
/** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */
// @ts-ignore Types are right.
export function paginationMiddleware(): DittoMiddleware<{ pagination: Pagination; paginate: PaginateFn }>;
export function paginationMiddleware(
type: 'list',
): DittoMiddleware<{ pagination: ListPagination; paginate: ListPaginateFn }>;
export function paginationMiddleware(
type?: string,
): DittoMiddleware<{ pagination?: Pagination | ListPagination; paginate: PaginateFn | ListPaginateFn }> {
return async (c, next) => {
const { relay } = c.var;
const pagination = paginationSchema.parse(c.req.query());
const {
max_id: maxId,
min_id: minId,
since,
until,
} = pagination;
if ((maxId && !until) || (minId && !since)) {
const ids: string[] = [];
if (maxId) ids.push(maxId);
if (minId) ids.push(minId);
if (ids.length) {
const events = await relay.query(
[{ ids, limit: ids.length }],
{ signal: c.req.raw.signal },
);
for (const event of events) {
if (!until && maxId === event.id) pagination.until = event.created_at;
if (!since && minId === event.id) pagination.since = event.created_at;
}
}
}
if (type === 'list') {
c.set('pagination', {
limit: pagination.limit,
offset: pagination.offset,
});
const fn: ListPaginateFn = (params, body, headers) => paginatedList(c, params, body, headers);
c.set('paginate', fn);
} else {
c.set('pagination', {
since: pagination.since,
until: pagination.until,
limit: pagination.limit,
});
const fn: PaginateFn = (events, body, headers) => paginated(c, events, body, headers);
c.set('paginate', fn);
}
await next();
};
}

View file

@ -0,0 +1,3 @@
export { buildLinkHeader, buildListLinkHeader } from './link-header.ts';
export { paginated, paginatedList } from './paginate.ts';
export { paginationSchema } from './schema.ts';

View file

@ -0,0 +1,23 @@
import { assertEquals } from '@std/assert';
import { paginationSchema } from './schema.ts';
Deno.test('paginationSchema', () => {
const pagination = paginationSchema.parse({
limit: '10',
offset: '20',
max_id: '1',
min_id: '2',
since: '3',
until: '4',
});
assertEquals(pagination, {
limit: 10,
offset: 20,
max_id: '1',
min_id: '2',
since: 3,
until: 4,
});
});

View file

@ -1,18 +1,21 @@
import { DittoRoute } from '@ditto/api'; import { DittoRoute } from '@ditto/api';
import { userMiddleware } from '@ditto/api/middleware'; import { paginationMiddleware, userMiddleware } from '@ditto/api/middleware';
import { booleanParamSchema, languageSchema } from '@ditto/api/schema'; import { booleanParamSchema, languageSchema } from '@ditto/api/schema';
import { getTagSet } from '@ditto/utils/tags';
import { z } from 'zod'; import { z } from 'zod';
import type { Context } from '@hono/hono';
import type { NostrFilter } from '@nostrify/nostrify'; import type { NostrFilter } from '@nostrify/nostrify';
import type { DittoEnv } from '../DittoEnv.ts';
const route = new DittoRoute().use(userMiddleware({ privileged: false, required: true })); const route = new DittoRoute().use(paginationMiddleware());
const homeQuerySchema = z.object({ const homeQuerySchema = z.object({
exclude_replies: booleanParamSchema.optional(), exclude_replies: booleanParamSchema.optional(),
only_media: booleanParamSchema.optional(), only_media: booleanParamSchema.optional(),
}); });
route.get('/home', async (c) => { route.get('/home', userMiddleware({ privileged: false, required: true }), async (c) => {
const { user, pagination } = c.var; const { user, pagination } = c.var;
const pubkey = await user?.signer.getPublicKey()!; const pubkey = await user?.signer.getPublicKey()!;
@ -50,7 +53,7 @@ const publicQuerySchema = z.object({
language: languageSchema.optional(), language: languageSchema.optional(),
}); });
const publicTimelineController: AppController = (c) => { route.get('/public', (c) => {
const { conf, pagination } = c.var; const { conf, pagination } = c.var;
const result = publicQuerySchema.safeParse(c.req.query()); const result = publicQuerySchema.safeParse(c.req.query());
@ -79,30 +82,29 @@ const publicTimelineController: AppController = (c) => {
} }
return renderStatuses(c, [filter]); return renderStatuses(c, [filter]);
}; });
const hashtagTimelineController: AppController = (c) => { route.get('/tag/:hashtag', (c) => {
const { pagination } = c.var; const { pagination } = c.var;
const hashtag = c.req.param('hashtag')!.toLowerCase(); const hashtag = c.req.param('hashtag').toLowerCase();
return renderStatuses(c, [{ kinds: [1, 20], '#t': [hashtag], ...pagination }]); return renderStatuses(c, [{ kinds: [1, 20], '#t': [hashtag], ...pagination }]);
}; });
const suggestedTimelineController: AppController = async (c) => { route.get('/suggested', async (c) => {
const { conf } = c.var; const { conf, relay, pagination } = c.var;
const store = c.get('store');
const params = c.get('pagination');
const [follows] = await store.query( const [follows] = await relay.query(
[{ kinds: [3], authors: [conf.pubkey], limit: 1 }], [{ kinds: [3], authors: [conf.pubkey], limit: 1 }],
); );
const authors = [...getTagSet(follows?.tags ?? [], 'p')]; const authors = [...getTagSet(follows?.tags ?? [], 'p')];
return renderStatuses(c, [{ authors, kinds: [1, 20], ...params }]); return renderStatuses(c, [{ authors, kinds: [1, 20], ...pagination }]);
}; });
/** Render statuses for timelines. */ /** Render statuses for timelines. */
async function renderStatuses(c: AppContext, filters: NostrFilter[]) { async function renderStatuses(c: Context<DittoEnv>, filters: NostrFilter[]) {
const { conf, store, user, signal } = c.var; const { conf, store, user, signal } = c.var;
const opts = { signal, timeout: conf.db.timeouts.timelines }; const opts = { signal, timeout: conf.db.timeouts.timelines };

View file

@ -1,48 +0,0 @@
import { AppMiddleware } from '@/app.ts';
import { paginationSchema } from '@/schemas/pagination.ts';
/** Fixes compatibility with Mastodon apps by that don't use `Link` headers. */
export const paginationMiddleware: AppMiddleware = async (c, next) => {
const { store } = c.var;
const pagination = paginationSchema.parse(c.req.query());
const {
max_id: maxId,
min_id: minId,
since,
until,
} = pagination;
if ((maxId && !until) || (minId && !since)) {
const ids: string[] = [];
if (maxId) ids.push(maxId);
if (minId) ids.push(minId);
if (ids.length) {
const events = await store.query(
[{ ids, limit: ids.length }],
{ signal: c.req.raw.signal },
);
for (const event of events) {
if (!until && maxId === event.id) pagination.until = event.created_at;
if (!since && minId === event.id) pagination.since = event.created_at;
}
}
}
c.set('pagination', {
since: pagination.since,
until: pagination.until,
limit: pagination.limit,
});
c.set('listPagination', {
limit: pagination.limit,
offset: pagination.offset,
});
await next();
};