diff --git a/packages/notion/src/__tests__/queries.test.ts b/packages/notion/src/__tests__/queries.test.ts new file mode 100644 index 0000000..abff2f2 --- /dev/null +++ b/packages/notion/src/__tests__/queries.test.ts @@ -0,0 +1,232 @@ +import { describe, it } from 'node:test'; +import assert from 'node:assert/strict'; +import { + buildDatabaseFilter, + getBlockChildren, + getPage, + queryDatabase, + searchDatabases, + searchPages, +} from '../queries.js'; +import type { + NotionBlock, + NotionDatabase, + NotionListResponse, + NotionPage, +} from '../queries.js'; + +describe('Notion query helpers', () => { + it('builds a page search operation without undefined payload keys', () => { + const operation = searchPages({ query: 'investors', page_size: 10 }); + + assert.deepStrictEqual(operation, { + method: 'POST', + endpoint: '/v1/search', + data: { + query: 'investors', + page_size: 10, + filter: { + property: 'object', + value: 'page', + }, + }, + }); + assertNoUndefinedDeep(operation); + }); + + it('builds a database search operation with pagination', () => { + const operation = searchDatabases({ page_size: 5, start_cursor: 'cursor' }); + + assert.deepStrictEqual(operation, { + method: 'POST', + endpoint: '/v1/search', + data: { + page_size: 5, + start_cursor: 'cursor', + filter: { + property: 'object', + value: 'database', + }, + }, + }); + }); + + it('builds a database query operation with encoded ids and preserves filters, sorts, and pagination', () => { + const filter = { + property: 'Status', + select: { equals: 'Active' }, + }; + const sorts = [ + { property: 'Priority', direction: 'descending' }, + { timestamp: 'last_edited_time', direction: 'ascending' }, + ]; + const operation = queryDatabase('db id', { + filter, + sorts, + page_size: 25, + start_cursor: 'cursor-2', + }); + + assert.deepStrictEqual(operation, { + method: 'POST', + endpoint: '/v1/databases/db%20id/query', + data: { + filter, + sorts, + page_size: 25, + start_cursor: 'cursor-2', + }, + }); + }); + + it('builds a page fetch operation without a payload', () => { + const operation = getPage('page id'); + + assert.deepStrictEqual(operation, { + method: 'GET', + endpoint: '/v1/pages/page%20id', + }); + assert.ok(!('data' in operation)); + }); + + it('builds a block children operation with pagination in the query string', () => { + const operation = getBlockChildren('block id', { + page_size: 50, + start_cursor: 'abc', + }); + + assert.deepStrictEqual(operation, { + method: 'GET', + endpoint: '/v1/blocks/block%20id/children?page_size=50&start_cursor=abc', + }); + assert.ok(!('data' in operation)); + }); + + it('uses the expected default operators for common database filter types', () => { + assert.deepStrictEqual(buildDatabaseFilter({ + property: 'Name', + type: 'title', + value: 'investors', + }), { + property: 'Name', + title: { contains: 'investors' }, + }); + + assert.deepStrictEqual(buildDatabaseFilter({ + property: 'Summary', + type: 'rich_text', + value: 'memo', + }), { + property: 'Summary', + rich_text: { contains: 'memo' }, + }); + + assert.deepStrictEqual(buildDatabaseFilter({ + property: 'Published', + type: 'checkbox', + value: true, + }), { + property: 'Published', + checkbox: { equals: true }, + }); + + assert.deepStrictEqual(buildDatabaseFilter({ + property: 'Stage', + type: 'select', + value: 'Seed', + }), { + property: 'Stage', + select: { equals: 'Seed' }, + }); + + assert.deepStrictEqual(buildDatabaseFilter({ + property: 'Tags', + type: 'multi_select', + value: 'finance', + }), { + property: 'Tags', + multi_select: { contains: 'finance' }, + }); + + assert.deepStrictEqual(buildDatabaseFilter({ + property: 'Launch date', + type: 'date', + value: '2026-04-01', + }), { + property: 'Launch date', + date: { on_or_after: '2026-04-01' }, + }); + + assert.deepStrictEqual(buildDatabaseFilter({ + property: 'Employees', + type: 'number', + value: 10, + }), { + property: 'Employees', + number: { equals: 10 }, + }); + }); + + it('lets an explicit operator override the default when supported', () => { + const filter = buildDatabaseFilter({ + property: 'Name', + type: 'title', + value: 'inv', + operator: 'starts_with', + }); + + assert.deepStrictEqual(filter, { + property: 'Name', + title: { starts_with: 'inv' }, + }); + }); + + it('keeps exported response types assignable', () => { + const pageList: NotionListResponse = { + object: 'list', + results: [{ object: 'page', id: 'page-1', properties: {} }], + has_more: false, + next_cursor: null, + }; + const database: NotionDatabase = { + object: 'database', + id: 'db-1', + title: [], + description: [], + properties: {}, + }; + const block: NotionBlock = { + object: 'block', + id: 'block-1', + type: 'paragraph', + has_children: false, + }; + + expectType(pageList.results?.[0]); + expectType(database); + expectType(block); + assert.strictEqual(pageList.results?.[0]?.id, 'page-1'); + assert.strictEqual(database.id, 'db-1'); + assert.strictEqual(block.id, 'block-1'); + }); +}); + +function assertNoUndefinedDeep(value: unknown): void { + if (Array.isArray(value)) { + for (const item of value) { + assertNoUndefinedDeep(item); + } + return; + } + + if (!value || typeof value !== 'object') { + return; + } + + for (const [key, entry] of Object.entries(value)) { + assert.notStrictEqual(entry, undefined, `Expected ${key} to be defined`); + assertNoUndefinedDeep(entry); + } +} + +function expectType(_value: T): void {} diff --git a/packages/notion/src/index.ts b/packages/notion/src/index.ts index 93e094a..242e574 100644 --- a/packages/notion/src/index.ts +++ b/packages/notion/src/index.ts @@ -15,4 +15,22 @@ export * from './path-mapper.js'; export * from './search.js'; export * from './sync.js'; export * from './types.js'; +export { + buildDatabaseFilter, + getBlockChildren, + getPage, + queryDatabase, + searchDatabases, + searchPages, +} from './queries.js'; +export type { + NotionDatabaseFilter, + NotionDatabaseFilterInput, + NotionDatabaseFilterType, + NotionObject, + NotionPaginationOptions, + NotionProxyOperation, + NotionQueryDatabaseOptions, + NotionSearchOptions, +} from './queries.js'; export * from './writeback.js'; diff --git a/packages/notion/src/queries.ts b/packages/notion/src/queries.ts new file mode 100644 index 0000000..c3da097 --- /dev/null +++ b/packages/notion/src/queries.ts @@ -0,0 +1,381 @@ +export interface NotionProxyOperation { + endpoint: string; + method: 'GET' | 'POST'; + data?: Record; + headers?: Record; +} + +export interface NotionPaginationOptions { + page_size?: number; + start_cursor?: string; +} + +export interface NotionSearchOptions extends NotionPaginationOptions { + query?: string; + sort?: { + direction: 'ascending' | 'descending'; + timestamp: 'last_edited_time'; + }; +} + +export interface NotionQueryDatabaseOptions extends NotionPaginationOptions { + filter?: NotionDatabaseFilter; + sorts?: Array>; +} + +export type NotionDatabaseFilterType = + | 'title' + | 'rich_text' + | 'number' + | 'checkbox' + | 'select' + | 'multi_select' + | 'date' + | 'people' + | 'files' + | 'url' + | 'email' + | 'phone_number' + | 'relation' + | 'formula' + | 'created_time' + | 'created_by' + | 'last_edited_time' + | 'last_edited_by'; + +export interface NotionDatabaseFilterInput { + property: string; + value: unknown; + type?: NotionDatabaseFilterType; + operator?: string; +} + +export type NotionDatabaseFilter = Record; + +export interface NotionListResponse { + object?: 'list'; + results?: T[]; + next_cursor?: string | null; + has_more?: boolean; + type?: string; + page_or_database?: Record; +} + +export interface NotionObject { + object?: string; + id?: string; + created_time?: string; + last_edited_time?: string; + archived?: boolean; + in_trash?: boolean; + url?: string; + public_url?: string | null; + properties?: Record; + parent?: Record; + created_by?: Record; + last_edited_by?: Record; + [key: string]: unknown; +} + +export interface NotionPage extends NotionObject { + object?: 'page'; + properties?: Record; +} + +export interface NotionDatabase extends NotionObject { + object?: 'database'; + title?: unknown[]; + description?: unknown[]; + properties?: Record; +} + +export interface NotionBlock extends NotionObject { + object?: 'block'; + type?: string; + has_children?: boolean; +} + +const TIMESTAMP_FILTER_TYPES = new Set([ + 'created_time', + 'last_edited_time', +]); + +const CONTAINS_FILTER_TYPES = new Set([ + 'multi_select', + 'people', + 'relation', + 'created_by', + 'last_edited_by', +]); + +const BOOLEAN_EMPTY_OPERATORS = new Set([ + 'is_empty', + 'is_not_empty', +]); + +const EMPTY_OBJECT_OPERATORS = new Set([ + 'past_week', + 'past_month', + 'past_year', + 'this_week', + 'next_week', + 'next_month', + 'next_year', +]); + +export function searchPages(options: NotionSearchOptions = {}): NotionProxyOperation { + return buildSearchOperation('page', options); +} + +export function searchDatabases(options: NotionSearchOptions = {}): NotionProxyOperation { + return buildSearchOperation('database', options); +} + +export function queryDatabase( + databaseId: string, + options: NotionQueryDatabaseOptions = {}, +): NotionProxyOperation { + const data = compactRecord({ + filter: options.filter, + sorts: options.sorts, + page_size: options.page_size, + start_cursor: emptyStringToUndefined(options.start_cursor), + }); + + return { + method: 'POST', + endpoint: `/v1/databases/${encodeURIComponent(databaseId)}/query`, + ...(Object.keys(data).length > 0 ? { data } : {}), + }; +} + +export function getPage(pageId: string): NotionProxyOperation { + return { + method: 'GET', + endpoint: `/v1/pages/${encodeURIComponent(pageId)}`, + }; +} + +export function getBlockChildren( + blockId: string, + options: NotionPaginationOptions = {}, +): NotionProxyOperation { + return { + method: 'GET', + endpoint: withQueryString(`/v1/blocks/${encodeURIComponent(blockId)}/children`, { + page_size: options.page_size, + start_cursor: emptyStringToUndefined(options.start_cursor), + }), + }; +} + +export function buildDatabaseFilter(input: NotionDatabaseFilterInput): NotionDatabaseFilter { + const property = input.property.trim(); + const filterType = input.type ?? inferDatabaseFilterType(input.value); + const operator = input.operator ?? inferDefaultOperator(filterType, input.value); + + if (!property && !TIMESTAMP_FILTER_TYPES.has(filterType)) { + throw new Error('Notion database filters require a non-empty property name.'); + } + + if (TIMESTAMP_FILTER_TYPES.has(filterType)) { + return { + timestamp: filterType, + [filterType]: buildOperatorExpression(operator, input.value), + }; + } + + if (filterType === 'formula') { + return { + property, + formula: buildFormulaExpression(operator, input.value), + }; + } + + if (CONTAINS_FILTER_TYPES.has(filterType) && Array.isArray(input.value)) { + const values = input.value.filter(isDefined); + if (values.length === 0) { + throw new Error(`Notion ${filterType} filters require at least one value.`); + } + + if (values.length === 1) { + return { + property, + [filterType]: buildOperatorExpression(operator, values[0]), + }; + } + + return { + or: values.map((value) => ({ + property, + [filterType]: buildOperatorExpression(operator, value), + })), + }; + } + + return { + property, + [filterType]: buildOperatorExpression(operator, input.value), + }; +} + +function buildSearchOperation( + objectType: 'page' | 'database', + options: NotionSearchOptions, +): NotionProxyOperation { + return { + method: 'POST', + endpoint: '/v1/search', + data: compactRecord({ + query: emptyStringToUndefined(options.query), + sort: options.sort, + page_size: options.page_size, + start_cursor: emptyStringToUndefined(options.start_cursor), + filter: { + property: 'object', + value: objectType, + }, + }), + }; +} + +function inferDatabaseFilterType(value: unknown): NotionDatabaseFilterType { + if (typeof value === 'number') { + return 'number'; + } + + if (typeof value === 'boolean') { + return 'checkbox'; + } + + if (Array.isArray(value)) { + return 'multi_select'; + } + + if (value instanceof Date || isIsoDateString(value)) { + return 'date'; + } + + return 'rich_text'; +} + +function inferDefaultOperator(type: NotionDatabaseFilterType, value: unknown): string { + switch (type) { + case 'created_time': + case 'last_edited_time': + return 'after'; + case 'date': + return 'on_or_after'; + case 'title': + case 'rich_text': + return 'contains'; + case 'multi_select': + case 'people': + case 'relation': + case 'created_by': + case 'last_edited_by': + return 'contains'; + case 'files': + return value === false ? 'is_empty' : 'is_not_empty'; + case 'formula': + return 'equals'; + default: + return 'equals'; + } +} + +function buildFormulaExpression(operator: string, value: unknown): Record { + const subtype = inferFormulaSubtype(value); + return { + [subtype]: buildOperatorExpression(operator, normalizeFilterValue(value)), + }; +} + +function inferFormulaSubtype(value: unknown): 'checkbox' | 'date' | 'number' | 'string' { + if (typeof value === 'boolean') { + return 'checkbox'; + } + + if (typeof value === 'number') { + return 'number'; + } + + if (value instanceof Date || isIsoDateString(value)) { + return 'date'; + } + + return 'string'; +} + +function buildOperatorExpression(operator: string, value: unknown): Record { + if (BOOLEAN_EMPTY_OPERATORS.has(operator)) { + return { [operator]: true }; + } + + if (EMPTY_OBJECT_OPERATORS.has(operator)) { + return { [operator]: {} }; + } + + if (value === undefined || value === null) { + return { [operator]: true }; + } + + return { + [operator]: normalizeFilterValue(value), + }; +} + +function normalizeFilterValue(value: unknown): unknown { + if (value instanceof Date) { + return value.toISOString(); + } + + return value; +} + +function withQueryString( + endpoint: string, + params: Record, +): string { + const searchParams = new URLSearchParams(); + + for (const [key, value] of Object.entries(params)) { + if (value === undefined) { + continue; + } + + searchParams.set(key, String(value)); + } + + const query = searchParams.toString(); + return query ? `${endpoint}?${query}` : endpoint; +} + +function compactRecord( + input: Record, +): Record { + const output: Record = {}; + + for (const [key, value] of Object.entries(input)) { + if (value !== undefined) { + output[key] = value; + } + } + + return output; +} + +function emptyStringToUndefined(value: string | undefined): string | undefined { + return value && value.trim().length > 0 ? value : undefined; +} + +function isIsoDateString(value: unknown): value is string { + return ( + typeof value === 'string' && + /^\d{4}-\d{2}-\d{2}(?:T[\d:.+-]+Z?)?$/.test(value.trim()) + ); +} + +function isDefined(value: T | null | undefined): value is T { + return value !== null && value !== undefined; +}