This commit is contained in:
Philipinho
2026-04-18 13:13:53 +01:00
parent 081bb67239
commit f5b19316af
53 changed files with 4056 additions and 812 deletions
+111
View File
@@ -0,0 +1,111 @@
import { BadRequestException } from '@nestjs/common';
import { SortBuild, TailKey } from './sort';
type ValueType = 'numeric' | 'date' | 'bool' | 'text';
// Hard cap on decoded cursor size so a tampered cursor can't force a large
// JSON parse. Real cursors are <1KB (a handful of field values).
const MAX_CURSOR_DECODED_BYTES = 4096;
/*
* Null-safe cursor encoder. The previous encoder used a literal string
* sentinel `__null__` for NULLs, which could collide with real cell
* values. This encoder never sees NULL because sort expressions are
* sentinel-wrapped (see sort.ts). It also represents ±Infinity
* explicitly so JSON round-tripping is lossless.
*/
export function makeCursor(sorts: SortBuild[], tailKeys: TailKey[]) {
const types = new Map<string, ValueType>();
for (const s of sorts) types.set(s.key, s.valueType);
for (const k of tailKeys) types.set(k, 'text');
return {
encodeCursor(values: Array<[string, unknown]>): string {
const payload: Record<string, string> = {};
for (const [k, v] of values) {
payload[k] = encodeValue(v, types.get(k) ?? 'text');
}
return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url');
},
decodeCursor(
cursor: string,
fieldNames: string[],
): Record<string, string> {
let parsed: Record<string, string>;
try {
parsed = JSON.parse(
Buffer.from(cursor, 'base64url').toString('utf8'),
);
} catch {
throw new BadRequestException('Invalid cursor');
}
if (typeof parsed !== 'object' || parsed === null) {
throw new BadRequestException('Invalid cursor payload');
}
const out: Record<string, string> = {};
for (const name of fieldNames) {
if (!(name in parsed)) {
throw new BadRequestException(`Cursor missing field: ${name}`);
}
out[name] = parsed[name];
}
return out;
},
parseCursor(decoded: Record<string, string>): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [k, raw] of Object.entries(decoded)) {
out[k] = decodeValue(raw, types.get(k) ?? 'text');
}
return out;
},
};
}
function encodeValue(value: unknown, type: ValueType): string {
if (type === 'numeric') {
if (value === null || value === undefined) return '';
const n = typeof value === 'number' ? value : parseFloat(String(value));
if (n === Number.POSITIVE_INFINITY || String(value) === 'Infinity') {
return 'inf';
}
if (n === Number.NEGATIVE_INFINITY || String(value) === '-Infinity') {
return '-inf';
}
if (Number.isNaN(n)) return '';
return String(n);
}
if (type === 'date') {
if (value === null || value === undefined) return '';
if (value instanceof Date) return value.toISOString();
const s = String(value);
if (s === 'infinity') return 'inf';
if (s === '-infinity') return '-inf';
return s;
}
if (type === 'bool') {
return value ? '1' : '0';
}
return value == null ? '' : String(value);
}
function decodeValue(raw: string, type: ValueType): unknown {
if (type === 'numeric') {
if (raw === 'inf') return Number.POSITIVE_INFINITY;
if (raw === '-inf') return Number.NEGATIVE_INFINITY;
if (raw === '') return null;
return parseFloat(raw);
}
if (type === 'date') {
if (raw === 'inf') return 'infinity';
if (raw === '-inf') return '-infinity';
if (raw === '') return null;
return raw;
}
if (type === 'bool') {
return raw === '1';
}
return raw;
}
@@ -0,0 +1,86 @@
import { SelectQueryBuilder } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { BaseRow } from '@docmost/db/types/entity.types';
import { PaginationOptions } from '@docmost/db/pagination/pagination-options';
import {
CursorPaginationResult,
executeWithCursorPagination,
} from '@docmost/db/pagination/cursor-pagination';
import { FilterNode, SearchSpec, SortSpec } from './schema.zod';
import { buildWhere, PropertySchema } from './predicate';
import { buildSorts, CURSOR_TAIL_KEYS, SortBuild } from './sort';
import { buildSearch } from './search';
import { makeCursor } from './cursor';
export type EngineListOpts = {
filter?: FilterNode;
sorts?: SortSpec[];
search?: SearchSpec;
schema: PropertySchema;
pagination: PaginationOptions;
};
/*
* Top-level orchestrator. Callers (repos, services) provide a base
* Kysely query already scoped to the target base + workspace + alive
* rows; this adds search/filter/sort clauses and runs cursor pagination.
*/
export async function runListQuery(
base: SelectQueryBuilder<DB, 'baseRows', any>,
opts: EngineListOpts,
): Promise<CursorPaginationResult<BaseRow>> {
let qb = base;
if (opts.search) {
const spec = opts.search;
qb = qb.where((eb) => buildSearch(eb, spec));
}
if (opts.filter) {
const filter = opts.filter;
qb = qb.where((eb) => buildWhere(eb, filter, opts.schema));
}
const sortBuilds: SortBuild[] =
opts.sorts && opts.sorts.length > 0
? buildSorts(opts.sorts, opts.schema)
: [];
for (const sb of sortBuilds) {
qb = qb.select(sb.expression.as(sb.key)) as SelectQueryBuilder<
DB,
'baseRows',
any
>;
}
const cursor = makeCursor(sortBuilds, CURSOR_TAIL_KEYS);
const fields = [
...sortBuilds.map((sb) => ({
expression: sb.expression,
direction: sb.direction,
key: sb.key,
})),
{
expression: 'position' as const,
direction: 'asc' as const,
key: 'position' as const,
},
{
expression: 'id' as const,
direction: 'asc' as const,
key: 'id' as const,
},
];
return executeWithCursorPagination(qb as any, {
perPage: opts.pagination.limit,
cursor: opts.pagination.cursor,
beforeCursor: opts.pagination.beforeCursor,
fields: fields as any,
encodeCursor: cursor.encodeCursor as any,
decodeCursor: cursor.decodeCursor as any,
parseCursor: cursor.parseCursor as any,
}) as unknown as Promise<CursorPaginationResult<BaseRow>>;
}
@@ -0,0 +1,32 @@
import { sql, RawBuilder } from 'kysely';
/*
* Parameterised extractors wrapping the SQL helper functions installed
* by the bases-hardening migration. PropertyId always binds as a
* parameter — never string-interpolated. These replace every
* `sql.raw('cells->>...')` site in the old repo.
*/
export function textCell(propertyId: string): RawBuilder<string> {
return sql<string>`base_cell_text(cells, ${propertyId}::uuid)`;
}
export function numericCell(propertyId: string): RawBuilder<number> {
return sql<number>`base_cell_numeric(cells, ${propertyId}::uuid)`;
}
export function dateCell(propertyId: string): RawBuilder<Date> {
return sql<Date>`base_cell_timestamptz(cells, ${propertyId}::uuid)`;
}
export function boolCell(propertyId: string): RawBuilder<boolean> {
return sql<boolean>`base_cell_bool(cells, ${propertyId}::uuid)`;
}
export function arrayCell(propertyId: string): RawBuilder<unknown> {
return sql<unknown>`base_cell_array(cells, ${propertyId}::uuid)`;
}
export function escapeIlike(value: string): string {
return value.replace(/[%_\\]/g, '\\$&');
}
+44
View File
@@ -0,0 +1,44 @@
export {
MAX_FILTER_DEPTH,
MAX_FILTER_NODES,
MAX_SORTS,
conditionSchema,
filterGroupSchema,
filterNodeSchema,
listQuerySchema,
operatorSchema,
searchSchema,
sortSpecSchema,
sortsSchema,
validateFilterTree,
} from './schema.zod';
export type {
Condition,
FilterGroup,
FilterNode,
ListQuery,
Operator,
SearchSpec,
SortSpec,
} from './schema.zod';
export {
PropertyKind,
SYSTEM_COLUMN,
isSystemType,
propertyKind,
} from './kinds';
export type { PropertyKindValue } from './kinds';
export { buildWhere } from './predicate';
export type { PropertySchema } from './predicate';
export { buildSorts, CURSOR_TAIL_KEYS } from './sort';
export type { SortBuild, TailKey } from './sort';
export { makeCursor } from './cursor';
export { buildSearch } from './search';
export { runListQuery } from './engine';
export type { EngineListOpts } from './engine';
+57
View File
@@ -0,0 +1,57 @@
import { BasePropertyType } from '../base.schemas';
export const PropertyKind = {
TEXT: 'text',
NUMERIC: 'numeric',
DATE: 'date',
BOOL: 'bool',
SELECT: 'select',
MULTI: 'multi',
PERSON: 'person',
FILE: 'file',
SYS_USER: 'sys_user',
} as const;
export type PropertyKindValue = (typeof PropertyKind)[keyof typeof PropertyKind];
export function propertyKind(type: string): PropertyKindValue | null {
switch (type) {
case BasePropertyType.TEXT:
case BasePropertyType.URL:
case BasePropertyType.EMAIL:
return PropertyKind.TEXT;
case BasePropertyType.NUMBER:
return PropertyKind.NUMERIC;
case BasePropertyType.DATE:
case BasePropertyType.CREATED_AT:
case BasePropertyType.LAST_EDITED_AT:
return PropertyKind.DATE;
case BasePropertyType.CHECKBOX:
return PropertyKind.BOOL;
case BasePropertyType.SELECT:
case BasePropertyType.STATUS:
return PropertyKind.SELECT;
case BasePropertyType.MULTI_SELECT:
return PropertyKind.MULTI;
case BasePropertyType.PERSON:
return PropertyKind.PERSON;
case BasePropertyType.FILE:
return PropertyKind.FILE;
case BasePropertyType.LAST_EDITED_BY:
return PropertyKind.SYS_USER;
default:
return null;
}
}
// System property type → camelCase column name on `base_rows`.
// Kysely camel-case plugin maps to snake_case in SQL.
export const SYSTEM_COLUMN: Record<string, 'createdAt' | 'updatedAt' | 'lastUpdatedById'> = {
[BasePropertyType.CREATED_AT]: 'createdAt',
[BasePropertyType.LAST_EDITED_AT]: 'updatedAt',
[BasePropertyType.LAST_EDITED_BY]: 'lastUpdatedById',
};
export function isSystemType(type: string): boolean {
return type in SYSTEM_COLUMN;
}
@@ -0,0 +1,404 @@
import { Expression, ExpressionBuilder, sql, SqlBool } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { BaseProperty } from '@docmost/db/types/entity.types';
import { Condition, FilterNode } from './schema.zod';
import { PropertyKind, propertyKind, SYSTEM_COLUMN } from './kinds';
import {
arrayCell,
boolCell,
dateCell,
escapeIlike,
numericCell,
textCell,
} from './extractors';
export type PropertySchema = Map<
string,
Pick<BaseProperty, 'id' | 'type' | 'typeOptions'>
>;
type Eb = ExpressionBuilder<DB, 'baseRows'>;
const TRUE = sql<SqlBool>`TRUE`;
const FALSE = sql<SqlBool>`FALSE`;
export function buildWhere(
eb: Eb,
node: FilterNode,
schema: PropertySchema,
): Expression<SqlBool> {
if ('children' in node) {
if (node.children.length === 0) return TRUE;
const built = node.children.map((c) => buildWhere(eb, c, schema));
return node.op === 'and' ? eb.and(built) : eb.or(built);
}
return buildCondition(eb, node, schema);
}
function buildCondition(
eb: Eb,
cond: Condition,
schema: PropertySchema,
): Expression<SqlBool> {
const prop = schema.get(cond.propertyId);
if (!prop) return FALSE;
const sysCol = SYSTEM_COLUMN[prop.type];
if (sysCol) return systemCondition(eb, sysCol, prop.type, cond);
const kind = propertyKind(prop.type);
if (!kind) return FALSE;
switch (kind) {
case PropertyKind.TEXT:
return textCondition(eb, cond);
case PropertyKind.NUMERIC:
return numericCondition(eb, cond);
case PropertyKind.DATE:
return dateCondition(eb, cond);
case PropertyKind.BOOL:
return boolCondition(eb, cond);
case PropertyKind.SELECT:
return selectCondition(eb, cond);
case PropertyKind.MULTI:
return multiCondition(eb, cond);
case PropertyKind.PERSON:
return personCondition(eb, cond, prop);
case PropertyKind.FILE:
return arrayOfIdsCondition(eb, cond);
default:
return FALSE;
}
}
// --- per-kind handlers ------------------------------------------------
function textCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
const expr = textCell(cond.propertyId);
const val = cond.value;
switch (cond.op) {
case 'isEmpty':
return eb.or([
eb(expr as any, 'is', null),
eb(expr as any, '=', ''),
]);
case 'isNotEmpty':
return eb.and([
eb(expr as any, 'is not', null),
eb(expr as any, '!=', ''),
]);
case 'eq':
return val == null ? FALSE : eb(expr as any, '=', String(val));
case 'neq':
return val == null
? FALSE
: eb.or([
eb(expr as any, 'is', null),
eb(expr as any, '!=', String(val)),
]);
case 'contains':
return val == null
? FALSE
: eb(expr as any, 'ilike', `%${escapeIlike(String(val))}%`);
case 'ncontains':
return val == null
? FALSE
: eb.or([
eb(expr as any, 'is', null),
eb(expr as any, 'not ilike', `%${escapeIlike(String(val))}%`),
]);
case 'startsWith':
return val == null
? FALSE
: eb(expr as any, 'ilike', `${escapeIlike(String(val))}%`);
case 'endsWith':
return val == null
? FALSE
: eb(expr as any, 'ilike', `%${escapeIlike(String(val))}`);
default:
return FALSE;
}
}
function numericCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
const expr = numericCell(cond.propertyId);
const raw = cond.value;
const num = raw == null ? null : Number(raw);
const bad = num == null || Number.isNaN(num);
switch (cond.op) {
case 'isEmpty':
return eb(expr as any, 'is', null);
case 'isNotEmpty':
return eb(expr as any, 'is not', null);
case 'eq':
return bad ? FALSE : eb(expr as any, '=', num);
case 'neq':
return bad
? FALSE
: eb.or([eb(expr as any, 'is', null), eb(expr as any, '!=', num)]);
case 'gt':
return bad ? FALSE : eb(expr as any, '>', num);
case 'gte':
return bad ? FALSE : eb(expr as any, '>=', num);
case 'lt':
return bad ? FALSE : eb(expr as any, '<', num);
case 'lte':
return bad ? FALSE : eb(expr as any, '<=', num);
default:
return FALSE;
}
}
function dateCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
const expr = dateCell(cond.propertyId);
const raw = cond.value;
const bad = raw == null || raw === '';
switch (cond.op) {
case 'isEmpty':
return eb(expr as any, 'is', null);
case 'isNotEmpty':
return eb(expr as any, 'is not', null);
case 'eq':
return bad ? FALSE : eb(expr as any, '=', String(raw));
case 'neq':
return bad
? FALSE
: eb.or([
eb(expr as any, 'is', null),
eb(expr as any, '!=', String(raw)),
]);
case 'before':
return bad ? FALSE : eb(expr as any, '<', String(raw));
case 'after':
return bad ? FALSE : eb(expr as any, '>', String(raw));
case 'onOrBefore':
return bad ? FALSE : eb(expr as any, '<=', String(raw));
case 'onOrAfter':
return bad ? FALSE : eb(expr as any, '>=', String(raw));
default:
return FALSE;
}
}
function boolCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
const expr = boolCell(cond.propertyId);
switch (cond.op) {
case 'isEmpty':
return eb(expr as any, 'is', null);
case 'isNotEmpty':
return eb(expr as any, 'is not', null);
case 'eq':
return cond.value == null
? FALSE
: eb(expr as any, '=', Boolean(cond.value));
case 'neq':
return cond.value == null
? FALSE
: eb.or([
eb(expr as any, 'is', null),
eb(expr as any, '!=', Boolean(cond.value)),
]);
default:
return FALSE;
}
}
function selectCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
// Cell stores a single option UUID as string. Use text extractor.
const expr = textCell(cond.propertyId);
const val = cond.value;
switch (cond.op) {
case 'isEmpty':
return eb.or([
eb(expr as any, 'is', null),
eb(expr as any, '=', ''),
]);
case 'isNotEmpty':
return eb.and([
eb(expr as any, 'is not', null),
eb(expr as any, '!=', ''),
]);
case 'eq':
return val == null ? FALSE : eb(expr as any, '=', String(val));
case 'neq':
return val == null
? FALSE
: eb.or([
eb(expr as any, 'is', null),
eb(expr as any, '!=', String(val)),
]);
case 'any': {
const arr = asStringArray(val);
if (arr.length === 0) return FALSE;
return eb(expr as any, 'in', arr);
}
case 'none': {
const arr = asStringArray(val);
if (arr.length === 0) return TRUE;
return eb.or([
eb(expr as any, 'is', null),
eb(expr as any, 'not in', arr),
]);
}
default:
return FALSE;
}
}
function multiCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
return arrayOfIdsCondition(eb, cond);
}
function personCondition(
eb: Eb,
cond: Condition,
prop: Pick<BaseProperty, 'id' | 'type' | 'typeOptions'>,
): Expression<SqlBool> {
// Person cells may be stored as a single uuid or an array of uuids depending
// on the property's `allowMultiple` option. Normalise to array semantics via
// `base_cell_array` when it's stored as an array, else text.
const allowMultiple = !!(prop.typeOptions as any)?.allowMultiple;
if (allowMultiple) return arrayOfIdsCondition(eb, cond);
const expr = textCell(cond.propertyId);
const val = cond.value;
switch (cond.op) {
case 'isEmpty':
return eb.or([
eb(expr as any, 'is', null),
eb(expr as any, '=', ''),
]);
case 'isNotEmpty':
return eb.and([
eb(expr as any, 'is not', null),
eb(expr as any, '!=', ''),
]);
case 'eq':
return val == null ? FALSE : eb(expr as any, '=', String(val));
case 'neq':
return val == null
? FALSE
: eb.or([
eb(expr as any, 'is', null),
eb(expr as any, '!=', String(val)),
]);
case 'any': {
const arr = asStringArray(val);
if (arr.length === 0) return FALSE;
return eb(expr as any, 'in', arr);
}
default:
return FALSE;
}
}
function arrayOfIdsCondition(eb: Eb, cond: Condition): Expression<SqlBool> {
const expr = arrayCell(cond.propertyId);
const val = cond.value;
switch (cond.op) {
case 'isEmpty':
return eb.or([
eb(expr as any, 'is', null),
sql<SqlBool>`jsonb_array_length(${expr}) = 0`,
]);
case 'isNotEmpty':
return eb.and([
eb(expr as any, 'is not', null),
sql<SqlBool>`jsonb_array_length(${expr}) > 0`,
]);
case 'any': {
const arr = asStringArray(val);
if (arr.length === 0) return FALSE;
return sql<SqlBool>`${expr} ?| ${arr}`;
}
case 'all': {
const arr = asStringArray(val);
if (arr.length === 0) return TRUE;
// `::text::jsonb` because postgres.js auto-detects JSON-shaped strings
// as jsonb and re-encodes them, producing a jsonb *string* instead of
// an array. Without the text hop, the containment check never matches.
return sql<SqlBool>`${expr} @> ${JSON.stringify(arr)}::text::jsonb`;
}
case 'none': {
const arr = asStringArray(val);
if (arr.length === 0) return TRUE;
return eb.or([
eb(expr as any, 'is', null),
sql<SqlBool>`NOT (${expr} ?| ${arr})`,
]);
}
default:
return FALSE;
}
}
function systemCondition(
eb: Eb,
column: 'createdAt' | 'updatedAt' | 'lastUpdatedById',
propertyType: string,
cond: Condition,
): Expression<SqlBool> {
const ref = eb.ref(column);
const val = cond.value;
// lastEditedBy — UUID column; behaves like select (uuid equality, in, isEmpty).
if (propertyType === 'lastEditedBy') {
switch (cond.op) {
case 'isEmpty':
return eb(ref, 'is', null);
case 'isNotEmpty':
return eb(ref, 'is not', null);
case 'eq':
return val == null ? FALSE : eb(ref, '=', String(val));
case 'neq':
return val == null
? FALSE
: eb.or([eb(ref, 'is', null), eb(ref, '!=', String(val))]);
case 'any': {
const arr = asStringArray(val);
if (arr.length === 0) return FALSE;
return eb(ref, 'in', arr);
}
case 'none': {
const arr = asStringArray(val);
if (arr.length === 0) return TRUE;
return eb.or([eb(ref, 'is', null), eb(ref, 'not in', arr)]);
}
default:
return FALSE;
}
}
// createdAt / updatedAt — timestamptz columns (NOT NULL).
const bad = val == null || val === '';
switch (cond.op) {
case 'isEmpty':
return FALSE;
case 'isNotEmpty':
return TRUE;
case 'eq':
return bad ? FALSE : eb(ref, '=', String(val));
case 'neq':
return bad ? FALSE : eb(ref, '!=', String(val));
case 'before':
return bad ? FALSE : eb(ref, '<', String(val));
case 'after':
return bad ? FALSE : eb(ref, '>', String(val));
case 'onOrBefore':
return bad ? FALSE : eb(ref, '<=', String(val));
case 'onOrAfter':
return bad ? FALSE : eb(ref, '>=', String(val));
default:
return FALSE;
}
}
// --- utilities --------------------------------------------------------
function asStringArray(val: unknown): string[] {
if (val == null) return [];
if (Array.isArray(val)) return val.filter((v) => v != null).map(String);
return [String(val)];
}
export { TRUE as TRUE_EXPR, FALSE as FALSE_EXPR };
@@ -0,0 +1,100 @@
import { z } from 'zod';
export const MAX_FILTER_DEPTH = 5;
export const MAX_FILTER_NODES = 50;
export const MAX_SORTS = 5;
const uuid = z.uuid();
export const operatorSchema = z.enum([
'eq',
'neq',
'gt',
'gte',
'lt',
'lte',
'contains',
'ncontains',
'startsWith',
'endsWith',
'isEmpty',
'isNotEmpty',
'before',
'after',
'onOrBefore',
'onOrAfter',
'any',
'none',
'all',
]);
export type Operator = z.infer<typeof operatorSchema>;
export const conditionSchema = z.object({
propertyId: uuid,
op: operatorSchema,
value: z.unknown().optional(),
});
export type Condition = z.infer<typeof conditionSchema>;
export type FilterNode = Condition | FilterGroup;
export type FilterGroup = {
op: 'and' | 'or';
children: FilterNode[];
};
// Recursive Zod schema for grouped filter trees.
export const filterNodeSchema: z.ZodType<FilterNode> = z.lazy(() =>
z.union([conditionSchema, filterGroupSchema]),
);
export const filterGroupSchema: z.ZodType<FilterGroup> = z.lazy(() =>
z.object({
op: z.enum(['and', 'or']),
children: z.array(filterNodeSchema),
}),
);
// Count nodes + max depth to prevent pathological trees from reaching SQL.
export function validateFilterTree(node: FilterNode): void {
let nodes = 0;
const walk = (n: FilterNode, depth: number) => {
if (depth > MAX_FILTER_DEPTH) {
throw new Error(`Filter tree exceeds max depth ${MAX_FILTER_DEPTH}`);
}
nodes += 1;
if (nodes > MAX_FILTER_NODES) {
throw new Error(`Filter tree exceeds max node count ${MAX_FILTER_NODES}`);
}
if ('children' in n) {
for (const c of n.children) walk(c, depth + 1);
}
};
walk(node, 0);
}
export const sortSpecSchema = z.object({
propertyId: uuid,
direction: z.enum(['asc', 'desc']),
});
export type SortSpec = z.infer<typeof sortSpecSchema>;
export const sortsSchema = z.array(sortSpecSchema).max(MAX_SORTS);
export const searchSchema = z.object({
query: z.string().min(1).max(500),
mode: z.enum(['trgm', 'fts']).default('trgm'),
});
export type SearchSpec = z.infer<typeof searchSchema>;
// Top-level request DTO shape. The row controller DTO composes this.
export const listQuerySchema = z.object({
filter: filterGroupSchema.optional(),
sorts: sortsSchema.optional(),
search: searchSchema.optional(),
});
export type ListQuery = z.infer<typeof listQuerySchema>;
@@ -0,0 +1,27 @@
import { Expression, ExpressionBuilder, sql, SqlBool } from 'kysely';
import { DB } from '@docmost/db/types/db';
import { SearchSpec } from './schema.zod';
type Eb = ExpressionBuilder<DB, 'baseRows'>;
/*
* `search_text` and `search_tsv` are maintained by the base_rows search
* trigger installed in the bases-hardening migration. Both columns are
* indexed — pg_trgm GIN for ILIKE and standard GIN for tsvector.
*/
export function buildSearch(eb: Eb, spec: SearchSpec): Expression<SqlBool> {
const q = spec.query.trim();
if (!q) return sql<SqlBool>`TRUE`;
if (spec.mode === 'fts') {
// Accent-insensitive match via f_unaccent (same helper the search
// trigger uses when populating search_tsv / search_text).
return sql<SqlBool>`search_tsv @@ plainto_tsquery('english', f_unaccent(${q}))`;
}
// trigram ILIKE mode (default). escape %/_/\\ in user input so wildcards
// can't be injected.
const escaped = q.replace(/[%_\\]/g, '\\$&');
return sql<SqlBool>`search_text ILIKE ${'%' + escaped + '%'}`;
}
+112
View File
@@ -0,0 +1,112 @@
import { RawBuilder, sql } from 'kysely';
import { BaseProperty } from '@docmost/db/types/entity.types';
import { SortSpec } from './schema.zod';
import { PropertyKind, SYSTEM_COLUMN, propertyKind } from './kinds';
import {
boolCell,
dateCell,
numericCell,
textCell,
} from './extractors';
import { PropertySchema } from './predicate';
/*
* Builds sort expressions with sentinel wrapping so NULLs compare
* deterministically at the end of the sort order. This avoids the
* `__null__` string sentinel bug in the old cursor encoder: because the
* sort expression never returns NULL, the cursor simply stores the
* extracted value and keyset comparisons work natively.
*/
export type SortBuild = {
key: string; // alias used in cursor (s0, s1, ...)
expression: RawBuilder<any>; // COALESCE-wrapped expression with sentinel
direction: 'asc' | 'desc';
valueType: 'numeric' | 'date' | 'text' | 'bool';
};
export type TailKey = 'position' | 'id';
export const CURSOR_TAIL_KEYS: TailKey[] = ['position', 'id'];
export function buildSorts(
sorts: SortSpec[],
schema: PropertySchema,
): SortBuild[] {
const out: SortBuild[] = [];
for (let i = 0; i < sorts.length; i++) {
const s = sorts[i];
const prop = schema.get(s.propertyId);
if (!prop) continue;
const key = `s${i}`;
const dir = s.direction;
const sysCol = SYSTEM_COLUMN[prop.type];
if (sysCol) {
out.push({
key,
expression: sql`${sql.ref(sysCol)}`,
direction: dir,
valueType: prop.type === 'lastEditedBy' ? 'text' : 'date',
});
continue;
}
const kind = propertyKind(prop.type);
if (!kind) continue;
out.push(wrapWithSentinel(s.propertyId, kind, dir, key));
}
return out;
}
function wrapWithSentinel(
propertyId: string,
kind: Exclude<ReturnType<typeof propertyKind>, null>,
direction: 'asc' | 'desc',
key: string,
): SortBuild {
if (kind === PropertyKind.NUMERIC) {
const sentinel =
direction === 'asc'
? sql`'Infinity'::numeric`
: sql`'-Infinity'::numeric`;
return {
key,
expression: sql`COALESCE(${numericCell(propertyId)}, ${sentinel})`,
direction,
valueType: 'numeric',
};
}
if (kind === PropertyKind.DATE) {
const sentinel =
direction === 'asc'
? sql`'infinity'::timestamptz`
: sql`'-infinity'::timestamptz`;
return {
key,
expression: sql`COALESCE(${dateCell(propertyId)}, ${sentinel})`,
direction,
valueType: 'date',
};
}
if (kind === PropertyKind.BOOL) {
// false < true. ASC NULLS LAST => null → true; DESC NULLS LAST => null → false.
const sentinel = direction === 'asc' ? sql`TRUE` : sql`FALSE`;
return {
key,
expression: sql`COALESCE(${boolCell(propertyId)}, ${sentinel})`,
direction,
valueType: 'bool',
};
}
// TEXT / SELECT / MULTI / PERSON / FILE — sort by raw extracted text.
const sentinel = direction === 'asc' ? sql`chr(1114111)` : sql`''`;
return {
key,
expression: sql`COALESCE(${textCell(propertyId)}, ${sentinel})`,
direction,
valueType: 'text',
};
}