import { createAuthenticatedFetch } from 'kody:runtime'
import { resolveIntegrationName, type NotionAccountParams } from './accounts.ts'
import { inputRecord, optionalRecord, optionalString, requiredString } from './validation.ts'
export const NOTION_API_BASE_URL = 'https://api.notion.com/v1'
export const NOTION_INTEGRATION = 'notion'
export const NOTION_VERSION = '2026-03-11'
export type JsonRecord = Record<string, unknown>
export class NotionApiError extends Error {
readonly path: string
readonly status: number
readonly details: JsonRecord
readonly integration: string
constructor(path: string, status: number, details: JsonRecord, integration: string) {
super(
'Notion API ' +
path +
' via integration "' +
integration +
'" failed: ' +
(details.code || details.message || String(status)),
)
this.name = 'NotionApiError'
this.path = path
this.status = status
this.details = details
this.integration = integration
}
}
/** Per-integration authenticated fetch cache (Notion tokens are workspace-scoped). */
const authenticatedFetchByIntegration = new Map<string, typeof fetch>()
async function getAuthenticatedFetch(integration: string): Promise<typeof fetch> {
let authed = authenticatedFetchByIntegration.get(integration)
if (!authed) {
authed = await createAuthenticatedFetch(integration)
authenticatedFetchByIntegration.set(integration, authed)
}
return authed
}
export type NotionRequestOptions = NotionAccountParams & {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
query?: Record<string, string | number | undefined>
body?: JsonRecord
notionVersion?: string
}
function assertNotionVersion(version: string): string {
if (!/^\d{4}-\d{2}-\d{2}$/.test(version)) {
throw new Error("notionVersion must be a Notion API date version such as '2026-03-11'.")
}
return version
}
export function assertNotionPath(path: string): string {
if (!/^\/[a-zA-Z0-9/_.~-]+$/.test(path)) {
throw new Error("path must be an absolute Notion API path such as '/search' or '/pages/{id}'.")
}
return path
}
export async function notionRequest<T extends JsonRecord = JsonRecord>(
path: string,
options: NotionRequestOptions = {},
): Promise<T> {
assertNotionPath(path)
const integration = resolveIntegrationName(options)
const url = new URL(NOTION_API_BASE_URL + path)
for (const [key, value] of Object.entries(options.query ?? {})) {
if (value === undefined) continue
url.searchParams.set(key, String(value))
}
const headers: Record<string, string> = {
accept: 'application/json',
'Notion-Version': options.notionVersion
? assertNotionVersion(options.notionVersion)
: NOTION_VERSION,
}
if (options.body !== undefined) headers['content-type'] = 'application/json'
const authedFetch = await getAuthenticatedFetch(integration)
const response = await authedFetch(url.toString(), {
method: options.method ?? 'GET',
headers,
body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
})
const result = (await response.json()) as JsonRecord
if (!response.ok) {
throw new NotionApiError(path, response.status, result, integration)
}
return result as T
}
export function nextCursor(response: JsonRecord): string | null {
const cursor = response.next_cursor
return typeof cursor === 'string' && cursor.length > 0 ? cursor : null
}
type DatabaseContainer = {
data_sources?: Array<{ id?: string; name?: string }>
[key: string]: unknown
}
/**
* Resolve a database id to its single data source id. Databases are containers
* since Notion-Version 2025-09-03; rows and schema live on data sources.
* Throws when the database has multiple data sources — pass the specific
* dataSourceId instead in that case.
*/
export async function resolveDataSourceId(
databaseId: string,
accountParams: NotionAccountParams = {},
): Promise<string> {
const database = await notionRequest<DatabaseContainer>(
'/databases/' + encodeURIComponent(databaseId),
accountParams,
)
const dataSources = database.data_sources ?? []
if (dataSources.length === 1 && typeof dataSources[0].id === 'string') {
return dataSources[0].id
}
if (dataSources.length === 0) {
throw new Error('Database ' + databaseId + ' has no data sources.')
}
throw new Error(
'Database ' + databaseId + ' has multiple data sources; pass dataSourceId explicitly. Options: ' +
dataSources.map((source) => source.id + ' (' + source.name + ')').join(', '),
)
}
const allowedMethods = new Set(['GET', 'POST', 'PATCH', 'DELETE'])
const readOnlyPostPaths = new Set(['/search'])
function isReadOnlyRequest(method: string, path: string): boolean {
if (method === 'GET') return true
if (
method === 'POST' &&
(readOnlyPostPaths.has(path) || /^\/data_sources\/[^/]+\/query$/.test(path))
) {
return true
}
return false
}
/**
* Call any Notion API endpoint through a saved Notion OAuth integration.
* Pass `account` / `integration` to select a workspace (default: `notion`).
* Mutating requests require `confirm: true`; use `dryRun: true` to preview.
*/
export default async function request(params: Record<string, unknown> = {}) {
const input = inputRecord(params)
const path = assertNotionPath(requiredString(input, 'path'))
const method = (optionalString(input, 'method') ?? 'GET').toUpperCase()
if (!allowedMethods.has(method)) {
throw new Error('method must be GET, POST, PATCH, or DELETE.')
}
const body = optionalRecord(input, 'body')
const query = optionalRecord(input, 'query') as NotionRequestOptions['query']
const notionVersion = optionalString(input, 'notionVersion')
const account = optionalString(input, 'account')
const integration = optionalString(input, 'integration')
if (!isReadOnlyRequest(method, path)) {
if (input.dryRun === true) {
return {
dryRun: true,
method,
path,
query: query ?? null,
body: body ?? null,
integration: resolveIntegrationName({ account, integration }),
}
}
if (input.confirm !== true) {
throw new Error(method + ' ' + path + ' mutates Notion data and requires confirm: true after explicit user approval. Use dryRun: true to preview.')
}
}
return notionRequest(path, {
method: method as NotionRequestOptions['method'],
query,
body,
notionVersion,
account,
integration,
})
}