feat(domain): centralize data definition

This commit is contained in:
2025-11-17 00:04:27 +02:00
parent e7585aa76c
commit f39635e04f
96 changed files with 3474 additions and 1167 deletions
+4
View File
@@ -1,5 +1,6 @@
{
"dependencies": {
"@basango/domain": "workspace:*",
"@basango/encryption": "workspace:*",
"@basango/logger": "workspace:*",
"@date-fns/utc": "^2.1.1",
@@ -23,6 +24,9 @@
"./schema": "./src/schema.ts",
"./utils": "./src/utils/index.ts"
},
"imports": {
"#db/*": "./src/*"
},
"name": "@basango/db",
"private": true,
"scripts": {
-31
View File
@@ -1,31 +0,0 @@
/**
* Base URL for source images.
* This URL is used to construct the full path to source images stored on the server.
*/
export const SOURCE_IMAGE_BASE = "https://devscast.org/images/sources/";
/**
* Number of days to include in the publication graph for sources.
* This defines the time range for which publication data is aggregated and displayed.
*/
export const PUBLICATION_GRAPH_DAYS = 30;
/**
* Maximum number of category shares to return for a source.
* This limits the number of categories displayed in the category share breakdown.
*/
export const CATEGORY_SHARES_LIMIT = 10;
/**
* The default timezone
*/
export const TIMEZONE = "Africa/Lubumbashi";
/**
* Default pagination settings.
* These constants define the default page number, default limit per page,
* and maximum limit allowed for paginated requests.
*/
export const PAGINATION_DEFAULT_PAGE = 1;
export const PAGINATION_DEFAULT_LIMIT = 5;
export const PAGINATION_MAX_LIMIT = 100;
+187 -25
View File
@@ -1,26 +1,45 @@
import { DEFAULT_TIMEZONE } from "@basango/domain/constants";
import {
Distribution,
Distributions,
ID,
PaginationState,
Publication,
Publications,
Sentiment,
} from "@basango/domain/models";
import { md5 } from "@basango/encryption";
import { count, eq } from "drizzle-orm";
import type { SQL } from "drizzle-orm";
import { count, desc, eq, getTableColumns, sql } from "drizzle-orm";
import { v7 as uuidV7 } from "uuid";
import { Database } from "#db/client";
import { getSourceIdByName } from "#db/queries/sources";
import { ArticleMetadata, Sentiment, TokenStatistics, articles } from "#db/schema";
import { computeReadingTime, computeTokenStatistics } from "#db/utils/computed";
export type CreateArticleParams = {
title: string;
body: string;
categories: string[];
link: string;
sourceId: string;
publishedAt: Date;
sentiment?: Sentiment;
tokenStatistics?: TokenStatistics;
readingTime?: number;
metadata?: ArticleMetadata;
};
import { articles, sources } from "#db/schema";
import { CreateArticleParams, GetArticlesParams } from "#db/types/articles";
import { GetDistributionsParams, GetPublicationsParams } from "#db/types/shared";
import {
applyFilters,
buildDateRange,
buildKeysetFilter,
buildPaginatedResult,
buildPaginationState,
buildPreviousRange,
buildSearchQuery,
computeDelta,
computeReadingTime,
computeTokenStatistics,
} from "#db/utils";
export async function createArticle(db: Database, params: CreateArticleParams) {
const duplicated = await getArticleByHash(db, md5(params.link));
if (duplicated !== undefined) {
return {
id: duplicated.id,
sourceId: duplicated.sourceId,
};
}
const data = {
...params,
hash: md5(params.link),
@@ -34,14 +53,6 @@ export async function createArticle(db: Database, params: CreateArticleParams) {
}),
};
const duplicated = await getArticleByHash(db, data.hash);
if (duplicated !== undefined) {
return {
id: duplicated.id,
sourceId: duplicated.sourceId,
};
}
const [result] = await db
.insert(articles)
.values({ id: uuidV7(), ...data })
@@ -63,7 +74,13 @@ export async function getArticleByHash(db: Database, hash: string) {
});
}
export async function countArticlesBySourceId(db: Database, sourceId: string) {
export async function getArticleById(db: Database, id: ID) {
return await db.query.articles.findFirst({
where: eq(articles.id, id),
});
}
export async function countArticlesBySourceId(db: Database, sourceId: ID) {
const result = await db
.select({ count: count(articles.id) })
.from(articles)
@@ -72,3 +89,148 @@ export async function countArticlesBySourceId(db: Database, sourceId: string) {
return result?.count ?? 0;
}
function buildFilters(params: GetArticlesParams, pagination: PaginationState) {
const filters: SQL<unknown>[] = [];
if (params.sourceId) {
filters.push(eq(articles.sourceId, params.sourceId));
}
if (params.sentiment) {
filters.push(eq(articles.sentiment, params.sentiment as Sentiment));
}
if (params.category) {
filters.push(sql`${params.category} = ANY(${articles.categories})`);
}
if (params.search?.trim()) {
const query = buildSearchQuery(params.search);
if (query) {
filters.push(sql`${articles.tsv} @@ to_tsquery('french', ${query})`);
}
}
const cursorFilter = buildKeysetFilter({
cursor: pagination.payload,
date: articles.publishedAt,
id: articles.id,
});
if (cursorFilter !== undefined) {
filters.push(cursorFilter);
}
return filters;
}
export async function getArticles(db: Database, params: GetArticlesParams) {
const pagination = buildPaginationState(params);
const filters = buildFilters(params, pagination);
const query = db
.select({
...getTableColumns(articles),
source: {
...getTableColumns(sources),
},
})
.from(articles)
.innerJoin(sources, eq(articles.sourceId, sources.id));
const rows = await applyFilters(query, filters)
.orderBy(desc(articles.publishedAt), desc(articles.id))
.limit(pagination.limit + 1);
return buildPaginatedResult(rows, pagination, {
date: "publishedAt",
id: "id",
});
}
export async function getArticlesPublicationGraph(
db: Database,
params: GetPublicationsParams,
): Promise<Publications> {
const [startDate, endDate] = buildDateRange(params.range);
const [previousRangeStart, previousRangeEnd] = buildPreviousRange([startDate, endDate]);
const data = await db.execute<Publication>(sql`
WITH bounds AS (
SELECT
${startDate}::timestamptz AS start_ts,
${endDate}::timestamptz AS end_ts
),
series AS (
SELECT (gs)::date AS d
FROM bounds b,
LATERAL generate_series(
date_trunc('day', timezone(${DEFAULT_TIMEZONE}, b.start_ts)),
date_trunc('day', timezone(${DEFAULT_TIMEZONE}, b.end_ts)),
INTERVAL '1 day'
) AS gs
),
counts AS (
SELECT
a.published_at::date AS d,
COUNT(*)::int AS c
FROM article a, bounds b
WHERE a.published_at >= timezone(${DEFAULT_TIMEZONE}, b.start_ts)
AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, b.end_ts)
GROUP BY 1
)
SELECT
to_char(s.d, 'YYYY-MM-DD') AS date,
COALESCE(c.c, 0) AS count
FROM series s
LEFT JOIN counts c USING (d)
ORDER BY s.d ASC
`);
const [previous] = await db
.execute<{ count: number }>(
sql`
SELECT COALESCE(COUNT(*)::int, 0) AS count
FROM article a
WHERE a.published_at >= timezone(${DEFAULT_TIMEZONE}, ${previousRangeStart})
AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, ${previousRangeEnd})
`,
)
.then((res) => res.rows);
const currentTotal = data.rows.reduce((acc, item) => acc + item.count, 0);
const previousTotal = previous?.count ?? 0;
return {
items: data.rows,
meta: {
current: currentTotal,
delta: computeDelta(currentTotal, previousTotal),
previous: previousTotal,
},
};
}
export async function getArticlesSourceDistribution(
db: Database,
params: GetDistributionsParams,
): Promise<Distributions> {
const data = await db.execute<Distribution>(sql`
SELECT
${sources.id}::text AS id,
${sources.name} AS name,
COUNT(${articles.id})::int AS count,
ROUND((COUNT(*)::numeric / SUM(COUNT(*)) OVER ()) * 100, 2)::float AS percentage
FROM ${articles}
JOIN ${sources} ON ${sources.id} = ${articles.sourceId}
GROUP BY ${sources.id}, ${sources.name}
ORDER BY count DESC
LIMIT ${params.limit ?? 10}
`);
return {
items: data.rows,
total: data.rows.reduce((acc, item) => acc + item.count, 0),
};
}
+25 -77
View File
@@ -1,11 +1,19 @@
import { endOfDay, startOfDay, subDays } from "date-fns";
import { DEFAULT_CATEGORY_SHARES_LIMIT, DEFAULT_TIMEZONE } from "@basango/domain/constants";
import { ID, Publication, Publications } from "@basango/domain/models";
import { eq, sql } from "drizzle-orm";
import { v7 as uuidV7 } from "uuid";
import { Database } from "#db/client";
import { CATEGORY_SHARES_LIMIT, PUBLICATION_GRAPH_DAYS, TIMEZONE } from "#db/constants";
import { NotFoundError } from "#db/errors";
import { Credibility, articles, sources } from "#db/schema";
import { articles, sources } from "#db/schema";
import {
CategoryShare,
CategoryShares,
GetCategorySharesParams,
GetPublicationsParams,
} from "#db/types/shared";
import { CreateSourceParams, UpdateSourceParams } from "#db/types/sources";
import { buildDateRange } from "#db/utils";
import { countArticlesBySourceId } from "./articles";
@@ -21,14 +29,6 @@ export async function getSources(db: Database) {
);
}
export type CreateSourceParams = {
name: string;
url: string;
displayName?: string;
description?: string;
credibility?: Credibility;
};
export async function createSource(db: Database, params: CreateSourceParams) {
const [result] = await db
.insert(sources)
@@ -38,14 +38,6 @@ export async function createSource(db: Database, params: CreateSourceParams) {
return result;
}
export type UpdateSourceParams = {
id: string;
name?: string;
displayName?: string;
description?: string;
credibility?: Credibility;
};
export async function updateSource(db: Database, params: UpdateSourceParams) {
const [result] = await db
.update(sources)
@@ -65,12 +57,8 @@ export async function updateSource(db: Database, params: UpdateSourceParams) {
return result;
}
export type DeleteSourceParams = {
id: string;
};
export async function deleteSource(db: Database, params: DeleteSourceParams) {
const [result] = await db.delete(sources).where(eq(sources.id, params.id)).returning();
export async function deleteSource(db: Database, id: ID) {
const [result] = await db.delete(sources).where(eq(sources.id, id)).returning();
return result;
}
@@ -81,7 +69,7 @@ export async function getSourceByName(db: Database, name: string) {
});
}
export async function getSourceById(db: Database, id: string) {
export async function getSourceById(db: Database, id: ID) {
const item = await db.query.sources.findFirst({
where: eq(sources.id, id),
});
@@ -108,48 +96,13 @@ export async function getSourceIdByName(db: Database, name: string): Promise<str
return result.id;
}
export type GetSourceByIdParams = {
id: string;
};
export type PublicationEntry = {
date: string;
count: number;
};
export type PublicationGraph = {
items: PublicationEntry[];
total: number;
};
export type CategoryShare = {
category: string;
count: number;
percentage: number;
};
export type CategoryShares = {
items: CategoryShare[];
total: number;
};
export type GetSourcePublicationGraphParams = {
id: string;
days?: number;
range?: {
from: Date;
to: Date;
};
};
export async function getSourcePublicationGraph(
db: Database,
params: GetSourcePublicationGraphParams,
): Promise<PublicationGraph> {
const endDate = endOfDay(new Date());
const startDate = startOfDay(subDays(endDate, params.days ?? PUBLICATION_GRAPH_DAYS - 1));
params: GetPublicationsParams,
): Promise<Publications> {
const [startDate, endDate] = buildDateRange(params.range);
const data = await db.execute<PublicationEntry>(sql`
const data = await db.execute<Publication>(sql`
WITH bounds AS (
SELECT
${startDate}::timestamptz AS start_ts,
@@ -159,8 +112,8 @@ export async function getSourcePublicationGraph(
SELECT (gs)::date AS d
FROM bounds b,
LATERAL generate_series(
date_trunc('day', timezone(${TIMEZONE}, b.start_ts)),
date_trunc('day', timezone(${TIMEZONE}, b.end_ts)),
date_trunc('day', timezone(${DEFAULT_TIMEZONE}, b.start_ts)),
date_trunc('day', timezone(${DEFAULT_TIMEZONE}, b.end_ts)),
INTERVAL '1 day'
) AS gs
),
@@ -170,8 +123,8 @@ export async function getSourcePublicationGraph(
COUNT(*)::int AS c
FROM article a, bounds b
WHERE a.source_id = ${params.id}::uuid
AND a.published_at >= timezone(${TIMEZONE}, b.start_ts)
AND a.published_at <= timezone(${TIMEZONE}, b.end_ts)
AND a.published_at >= timezone(${DEFAULT_TIMEZONE}, b.start_ts)
AND a.published_at <= timezone(${DEFAULT_TIMEZONE}, b.end_ts)
GROUP BY 1
)
SELECT
@@ -182,17 +135,12 @@ export async function getSourcePublicationGraph(
ORDER BY s.d ASC
`);
return { items: data.rows, total: data.rows.length };
return { items: data.rows };
}
export type GetSourceCategorySharesParams = {
id: string;
limit?: number;
};
export async function getSourceCategoryShares(
db: Database,
params: GetSourceCategorySharesParams,
params: GetCategorySharesParams,
): Promise<CategoryShares> {
const data = await db.execute<CategoryShare>(sql`
SELECT
@@ -208,7 +156,7 @@ export async function getSourceCategoryShares(
WHERE cat IS NOT NULL
GROUP BY cat
ORDER BY count DESC
LIMIT ${params.limit ?? CATEGORY_SHARES_LIMIT}
LIMIT ${params.limit ?? DEFAULT_CATEGORY_SHARES_LIMIT}
`);
return { items: data.rows, total: data.rowCount ?? 0 };
+16 -83
View File
@@ -1,3 +1,12 @@
import { BIAS, RELIABILITY, SENTIMENT, TRANSPARENCY } from "@basango/domain/constants";
import {
ArticleMetadata,
Credibility,
Device,
GeoLocation,
Roles,
TokenStatistics,
} from "@basango/domain/models";
import { relations, sql } from "drizzle-orm";
import { check } from "drizzle-orm/gel-core";
import {
@@ -22,100 +31,24 @@ import {
/* Types */
/* -------------------------------------------------------------------------- */
export const tsvector = customType<{ data: string; driverData: string }>({
const tsvector = customType<{ data: string; driverData: string }>({
dataType() {
return "tsvector";
},
});
export const customJsonType = <T>() =>
customType<{ data: T }>({
dataType() {
return "jsonb";
},
fromDriver(value) {
return value as T;
},
toDriver(value) {
return value; // JSONB → just pass the object
},
});
pgEnum("bias", BIAS);
pgEnum("reliability", RELIABILITY);
pgEnum("transparency", TRANSPARENCY);
export const biasEnum = pgEnum("bias", ["neutral", "slightly", "partisan", "extreme"]);
export const reliabilityEnum = pgEnum("reliability", [
"trusted",
"reliable",
"average",
"low_trust",
"unreliable",
]);
export const sentimentEnum = pgEnum("sentiment", ["positive", "neutral", "negative"]);
export const transparencyEnum = pgEnum("transparency", ["high", "medium", "low"]);
export const tokenPurposeEnum = pgEnum("token_purpose", [
const sentimentEnum = pgEnum("sentiment", SENTIMENT);
const tokenPurposeEnum = pgEnum("token_purpose", [
"confirm_account",
"password_reset",
"unlock_account",
"delete_account",
]);
export type EmailAddress = string;
export type Link = string;
export type ReadingTime = number;
export type Role = "ROLE_USER" | "ROLE_ADMIN";
export type Roles = Role[];
export type Bias = (typeof biasEnum.enumValues)[number];
export type Reliability = (typeof reliabilityEnum.enumValues)[number];
export type Sentiment = (typeof sentimentEnum.enumValues)[number];
export type Transparency = (typeof transparencyEnum.enumValues)[number];
export type TokenPurpose = (typeof tokenPurposeEnum.enumValues)[number];
export type Credibility = {
bias: Bias;
reliability: Reliability;
transparency: Transparency;
};
export type TokenStatistics = {
title: number;
body: number;
categories: number;
excerpt: number;
total: number;
};
export type Device = {
operatingSystem?: string;
client?: string;
device?: string;
isBot: boolean;
};
export type GeoLocation = {
country?: string;
city?: string;
timeZone?: string;
longitude?: number;
latitude?: number;
accuracyRadius?: number;
};
export type ArticleMetadata = {
title?: string;
description?: string;
image?: string;
};
export type DateRange = {
start: number; // unix timestamp (seconds)
end: number; // unix timestamp (seconds)
};
// Secrets
export type GeneratedToken = string;
export type GeneratedCode = string;
/* -------------------------------------------------------------------------- */
/* Tables */
/* -------------------------------------------------------------------------- */
@@ -124,7 +57,7 @@ export const users = pgTable(
"user",
{
createdAt: timestamp("created_at").defaultNow().notNull(),
email: varchar({ length: 255 }).$type<EmailAddress>().notNull(),
email: varchar({ length: 255 }).notNull(),
id: uuid().primaryKey().notNull(),
isConfirmed: boolean("is_confirmed").default(false).notNull(),
isLocked: boolean("is_locked").default(false).notNull(),
+27
View File
@@ -0,0 +1,27 @@
import { ArticleMetadata, ID, Sentiment, TokenStatistics } from "@basango/domain/models";
export type CreateArticleParams = {
title: string;
body: string;
categories: string[];
link: string;
sourceId: string;
publishedAt: Date;
sentiment?: Sentiment;
tokenStatistics?: TokenStatistics;
readingTime?: number;
metadata?: ArticleMetadata;
};
export type GetArticleByIdParams = {
id: ID;
};
export type GetArticlesParams = {
cursor?: string | null;
limit?: number;
search?: string;
sentiment?: Sentiment;
sourceId?: string;
category?: string;
};
+26
View File
@@ -0,0 +1,26 @@
import { DateRange, ID } from "@basango/domain/models";
export type CategoryShare = {
category: string;
count: number;
percentage: number;
};
export type CategoryShares = {
items: CategoryShare[];
total: number;
};
export type GetPublicationsParams = {
id?: ID;
range?: DateRange;
};
export type GetCategorySharesParams = {
id: ID;
limit?: number;
};
export type GetDistributionsParams = {
limit?: number;
};
+21
View File
@@ -0,0 +1,21 @@
import { Credibility, ID } from "@basango/domain/models";
export type UpdateSourceParams = {
id: ID;
name?: string;
displayName?: string;
description?: string;
credibility?: Credibility;
};
export type CreateSourceParams = {
name: string;
url: string;
displayName?: string;
description?: string;
credibility?: Credibility;
};
export type GetSourceByIdParams = {
id: ID;
};
+15 -2
View File
@@ -1,7 +1,6 @@
import { Delta, TokenStatistics } from "@basango/domain/models";
import { TiktokenEncoding, get_encoding } from "tiktoken";
import { TokenStatistics } from "#db/schema";
/**
* Count the number of tokens in the given text using the specified encoding.
* @param text - The input text
@@ -57,3 +56,17 @@ export const computeReadingTime = (text: string, wordsPerMinute = 200): number =
const words = text.trim().split(/\s+/).length;
return Math.ceil(words / wordsPerMinute);
};
export const computeDelta = (current: number, previous: number): Delta => {
const delta = current - previous;
const percentage = previous === 0 ? (current === 0 ? 0 : 100) : (delta / previous) * 100;
const sign = delta >= 0 ? "+" : "-";
const variant = previous === 0 ? "positive" : delta >= 0 ? "increase" : "decrease";
return {
delta,
percentage,
sign,
variant,
};
};
+48
View File
@@ -0,0 +1,48 @@
import { DEFAULT_PUBLICATION_GRAPH_DAYS } from "@basango/domain/constants";
import { DateRange } from "@basango/domain/models";
import { endOfDay, startOfDay, subDays } from "date-fns";
export const buildSearchQuery = (input: string) => {
const trimmed = input.trim();
if (!trimmed) {
return "";
}
return trimmed
.split(/\s+/)
.map((term) => {
// Escape special characters for PostgreSQL full-text search
// Special characters: & | ! ( ) : * ' " + - ~
const escaped = term.toLowerCase().replace(/[&|!():*'"+~-]/g, "\\$&");
return `${escaped}:*`;
})
.join(" & ");
};
/**
* Resolve a date range given an explicit range.
* Defaults to the last 30 days when no range is provided.
*/
export function buildDateRange(range?: DateRange): [startDate: Date, endDate: Date] {
const endDate = endOfDay(range?.end ?? new Date());
const startDate = startOfDay(
range?.start ?? subDays(endDate, Math.max(DEFAULT_PUBLICATION_GRAPH_DAYS - 1, 0)),
);
return [startDate, endDate];
}
/**
* Given a [start, end] date range, produce the immediately preceding range of the same length.
*/
export function buildPreviousRange([startDate, endDate]: [Date, Date]): [Date, Date] {
const days = Math.max(
1,
Math.round((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1,
);
const previousRangeEnd = endOfDay(subDays(startDate, 1));
const previousRangeStart = startOfDay(subDays(previousRangeEnd, days - 1));
return [previousRangeStart, previousRangeEnd];
}
+1 -1
View File
@@ -1,3 +1,3 @@
export * from "./computed";
export * from "./filters";
export * from "./pagination";
export * from "./search-query";
+81 -51
View File
@@ -1,87 +1,96 @@
import { Buffer } from "node:buffer";
import {
PAGINATION_DEFAULT_LIMIT,
PAGINATION_DEFAULT_PAGE,
PAGINATION_MAX_LIMIT,
} from "#db/constants";
DEFAULT_PAGINATION_LIMIT,
DEFAULT_PAGINATION_MAX_LIMIT,
DEFAULT_PAGINATION_PAGE,
} from "@basango/domain/constants";
import {
PaginatedResult,
PaginationCursor,
PaginationRequest,
PaginationState,
} from "@basango/domain/models";
import { isValid, toDate } from "date-fns";
import { AnyColumn, SQL, and, eq, lt, or } from "drizzle-orm";
export interface PageRequest {
page?: number;
limit?: number;
cursor?: string | null;
}
export interface PageState {
page: number;
limit: number;
cursor: string | null;
offset: number;
}
export interface CursorPayload {
id: string;
date?: string | null;
}
export interface PaginationMeta {
current: number;
limit: number;
cursor: string | null;
hasNext: boolean;
}
export function createPageState(request: PageRequest = {}): PageState {
export function buildPaginationState(request: PaginationRequest = {}): PaginationState {
const page =
Number.isFinite(request.page) && (request.page ?? 0) > 0
? Math.trunc(request.page!)
: PAGINATION_DEFAULT_PAGE;
: DEFAULT_PAGINATION_PAGE;
let limit =
Number.isFinite(request.limit) && (request.limit ?? 0) > 0
? Math.trunc(request.limit!)
: PAGINATION_DEFAULT_LIMIT;
: DEFAULT_PAGINATION_LIMIT;
if (limit < PAGINATION_DEFAULT_LIMIT) {
limit = PAGINATION_DEFAULT_LIMIT;
if (limit < DEFAULT_PAGINATION_LIMIT) {
limit = DEFAULT_PAGINATION_LIMIT;
}
if (limit > PAGINATION_MAX_LIMIT) {
limit = PAGINATION_MAX_LIMIT;
if (limit > DEFAULT_PAGINATION_MAX_LIMIT) {
limit = DEFAULT_PAGINATION_MAX_LIMIT;
}
const cursor = request.cursor ?? null;
const payload = decodeCursor(cursor);
const offset = (page - 1) * limit;
return { cursor, limit, offset, page };
return { cursor, limit, offset, page, payload };
}
export function encodeCursor(
row: Record<string, unknown>,
keyset: { id: string; date?: string | null },
): string {
const payload: CursorPayload = {
id: String(row[keyset.id] ?? ""),
};
export function buildPaginatedResult<T>(
rows: T[],
pagination: PaginationState,
cursor: PaginationCursor,
): PaginatedResult<T> {
const hasNext = rows.length > pagination.limit;
const items = rows.slice(0, pagination.limit);
const lastItem = items[items.length - 1];
if (keyset.date) {
const value = row[keyset.date];
if (value !== undefined && value !== null) {
payload.date = String(value);
}
return {
items,
meta: {
current: pagination.page,
cursor: pagination.cursor,
hasNext,
limit: pagination.limit,
nextCursor: hasNext && lastItem ? encodeCursor(lastItem, cursor) : null,
},
};
}
export function applyFilters(
// biome-ignore lint/suspicious/noExplicitAny: drizzle types to be fixed
query: any,
filters: SQL<unknown>[],
): typeof query {
if (filters.length === 1) {
return query.where(filters[0]);
} else if (filters.length > 1) {
return query.where(and(...filters));
}
return query;
}
export function encodeCursor(row: Record<string, string>, keyset: PaginationCursor): string {
const payload: PaginationCursor = {
date: String(row[keyset.date]),
id: String(row[keyset.id]),
};
return Buffer.from(JSON.stringify(payload), "utf8").toString("base64");
}
export function decodeCursor(cursor?: string | null): CursorPayload | null {
export function decodeCursor(cursor?: string | null): PaginationCursor | null {
if (!cursor) {
return null;
}
try {
const decoded = Buffer.from(cursor, "base64").toString("utf8");
const payload = JSON.parse(decoded) as CursorPayload;
const payload = JSON.parse(decoded) as PaginationCursor;
if (!payload || payload.id.length === 0) {
return null;
@@ -92,3 +101,24 @@ export function decodeCursor(cursor?: string | null): CursorPayload | null {
return null;
}
}
type BuildKeysetOptions = {
cursor: PaginationCursor | null;
date: AnyColumn;
id: AnyColumn;
};
export function buildKeysetFilter(options: BuildKeysetOptions): SQL<unknown> | undefined {
if (!options.cursor) return undefined;
if (isValid(options.cursor.date)) {
const date = toDate(options.cursor.date);
return or(
lt(options.date, date),
and(eq(options.date, date), lt(options.id, options.cursor.id)),
);
}
return lt(options.id, options.cursor.id);
}
-16
View File
@@ -1,16 +0,0 @@
export const buildSearchQuery = (input: string) => {
const trimmed = input.trim();
if (!trimmed) {
return "";
}
return trimmed
.split(/\s+/)
.map((term) => {
// Escape special characters for PostgreSQL full-text search
// Special characters: & | ! ( ) : * ' " + - ~
const escaped = term.toLowerCase().replace(/[&|!():*'"+~-]/g, "\\$&");
return `${escaped}:*`;
})
.join(" & ");
};
-6
View File
@@ -1,10 +1,4 @@
{
"compilerOptions": {
"baseUrl": ".",
"paths": {
"#db/*": ["./src/*"]
}
},
"exclude": ["node_modules"],
"extends": "@basango/tsconfig/base.json",
"include": ["src"]