ai-knowledge-hub
Version:
MCP server that provides unified access to organizational knowledge across multiple platforms (local docs, Guru, Notion)
923 lines • 39.2 kB
JavaScript
import { extractPageTitle, extractTitleFromMarkdown, markdownToNotion, notionToMarkdown } from '../utils/converters.js';
import { readMarkdownFile, validateFilePath } from '../utils/file-system.js';
import { basename } from 'path';
export class NotionService {
config;
baseUrl = 'https://api.notion.com/v1';
propertyTypeCache = new Map();
constructor(config) {
this.config = config;
}
analyzeSearchMatch(page, searchTerm, searchMode) {
const metadata = {
searchMode: searchMode,
matchLocation: 'content',
matchedTerms: [],
};
const term = searchTerm.toLowerCase();
// Check tags property - safely access the multi_select array
const tagsProperty = page.properties.Tags;
if (tagsProperty !== undefined && tagsProperty !== null && 'multi_select' in tagsProperty) {
const multiSelect = tagsProperty.multi_select;
if (Array.isArray(multiSelect)) {
const matchingTags = multiSelect.filter(tag => tag?.name !== undefined && tag.name !== null && tag.name !== '' && tag.name.toLowerCase().includes(term));
if (matchingTags.length > 0) {
metadata.matchLocation = 'tags';
metadata.matchedTerms = matchingTags.map(tag => tag.name);
}
}
}
// Check title - find the property with type 'title'
const titleProperty = Object.values(page.properties).find(prop => prop !== undefined && prop !== null && typeof prop === 'object' && 'type' in prop && prop.type === 'title');
if (titleProperty !== undefined && titleProperty !== null && 'title' in titleProperty) {
const titleArray = titleProperty.title;
if (Array.isArray(titleArray) && titleArray[0]?.text?.content !== undefined && titleArray[0].text.content !== '') {
const titleText = titleArray[0].text.content.toLowerCase();
if (titleText.includes(term)) {
metadata.matchLocation = 'title';
metadata.matchedTerms = [searchTerm];
}
}
}
// Check description property
const descProperty = page.properties.Description;
if (descProperty !== undefined && descProperty !== null && 'rich_text' in descProperty) {
const richTextArray = descProperty.rich_text;
if (Array.isArray(richTextArray) && richTextArray[0]?.text?.content !== undefined && richTextArray[0].text.content !== '') {
const descText = richTextArray[0].text.content.toLowerCase();
if (descText.includes(term)) {
metadata.matchLocation = 'description';
metadata.matchedTerms = [searchTerm];
}
}
}
return metadata;
}
async getPropertyType(databaseId, propertyName) {
// Check cache first
const dbCache = this.propertyTypeCache.get(databaseId);
if (dbCache?.has(propertyName) === true) {
return dbCache.get(propertyName) ?? null;
}
try {
const database = await this.getDatabase(databaseId);
const propertyType = database.properties[propertyName]?.type ?? null;
// Cache the result
if (!this.propertyTypeCache.has(databaseId)) {
this.propertyTypeCache.set(databaseId, new Map());
}
this.propertyTypeCache.get(databaseId).set(propertyName, propertyType ?? '');
return propertyType;
}
catch {
// Property type lookup failed, return null
return null;
}
}
async setPropertyValue(properties, propertyName, value, databaseId) {
const propertyType = await this.getPropertyType(databaseId, propertyName);
if (propertyType === null) {
// Property not found in database schema, skip setting
return;
}
switch (propertyType) {
case 'select':
properties[propertyName] = { select: { name: String(value) } };
break;
case 'multi_select':
properties[propertyName] = {
multi_select: Array.isArray(value)
? value.map(v => ({ name: String(v) }))
: [{ name: String(value) }],
};
break;
case 'title':
properties[propertyName] = {
title: [{ text: { content: String(value) } }],
};
break;
case 'rich_text':
properties[propertyName] = {
rich_text: [{ text: { content: String(value) } }],
};
break;
case 'checkbox':
properties[propertyName] = { checkbox: Boolean(value) };
break;
case 'number':
properties[propertyName] = { number: Number(value) };
break;
case 'url':
properties[propertyName] = { url: String(value) };
break;
default:
// Unsupported property type, skip setting
break;
}
}
async makeRequest(endpoint, method = 'GET', body) {
const url = `${this.baseUrl}${endpoint}`;
const headers = {
'Authorization': `Bearer ${this.config.token}`,
'Notion-Version': this.config.version ?? '2022-06-28',
'Content-Type': 'application/json',
};
const options = {
method,
headers,
};
if (body !== undefined && (method === 'POST' || method === 'PATCH')) {
options.body = JSON.stringify(body);
}
try {
const response = await fetch(url, options);
if (!response.ok) {
const errorText = await response.text();
let errorData;
try {
errorData = JSON.parse(errorText);
}
catch {
errorData = { message: errorText };
}
throw new Error(`Notion API Error ${response.status}: ${errorData.message ?? response.statusText}`);
}
const data = await response.json();
return data;
}
catch (error) {
if (error instanceof Error) {
throw error;
}
throw new Error(`Request failed: ${String(error)}`);
}
}
// Database operations (only for MCP Access Database)
async getDatabase(databaseId) {
return this.makeRequest(`/databases/${databaseId}`);
}
async queryDatabase(request) {
const { database_id, ...queryBody } = request;
return this.makeRequest(`/databases/${database_id}/query`, 'POST', queryBody);
}
async updateDatabase(databaseId, updateData) {
return this.makeRequest(`/databases/${databaseId}`, 'PATCH', updateData);
}
// Page operations
async getPage(pageId) {
return this.makeRequest(`/pages/${pageId}`);
}
async createPage(request) {
return this.makeRequest('/pages', 'POST', request);
}
async updatePage(pageId, request) {
return this.makeRequest(`/pages/${pageId}`, 'PATCH', request);
}
async archivePage(pageId) {
return this.makeRequest(`/pages/${pageId}`, 'PATCH', {
archived: true,
});
}
// Block operations
async getBlockChildren(blockId, startCursor) {
const params = new URLSearchParams();
if (startCursor !== undefined) {
params.append('start_cursor', startCursor);
}
const queryString = params.toString();
const url = `/blocks/${blockId}/children${queryString.length > 0 ? `?${queryString}` : ''}`;
return this.makeRequest(url);
}
async appendBlockChildren(blockId, request) {
return this.makeRequest(`/blocks/${blockId}/children`, 'PATCH', request);
}
/**
* Append blocks to a page/block with automatic chunking for 100-block limit
*/
async appendBlockChildrenChunked(blockId, blocks, maxBlocksPerRequest = 100) {
const results = [];
// Split blocks into chunks of maxBlocksPerRequest
for (let i = 0; i < blocks.length; i += maxBlocksPerRequest) {
const chunk = blocks.slice(i, i + maxBlocksPerRequest);
const result = await this.appendBlockChildren(blockId, {
children: chunk,
});
results.push(result);
}
return results;
}
async updateBlock(blockId, updateData) {
return this.makeRequest(`/blocks/${blockId}`, 'PATCH', updateData);
}
async deleteBlock(blockId) {
return this.makeRequest(`/blocks/${blockId}`, 'DELETE');
}
// Comment operations
async getComments(pageId) {
return this.makeRequest(`/comments?block_id=${pageId}`);
}
async createComment(request) {
return this.makeRequest('/comments', 'POST', request);
}
// Legacy sequential method - kept for internal use in parallel implementation
async getAllBlocksRecursively(blockId) {
const allBlocks = [];
let hasMore = true;
let startCursor;
while (hasMore) {
const response = await this.getBlockChildren(blockId, startCursor);
for (const block of response.results) {
allBlocks.push(block);
// Recursively get children if they exist
if (block.has_children) {
const children = await this.getAllBlocksRecursively(block.id);
// Add children property to the block
block.children = children;
}
}
hasMore = response.has_more;
startCursor = response.next_cursor ?? undefined;
}
return allBlocks;
}
// Optimized breadth-first parallel version for fetching Notion blocks
async getAllBlocksRecursivelyParallel(blockId, maxConcurrency = 5) {
// Create a semaphore to limit concurrent API calls
const { Sema } = await import('async-sema');
const semaphore = new Sema(maxConcurrency, {
capacity: maxConcurrency,
});
// Helper to fetch all pages for a single block (handles pagination)
const fetchAllPagesForBlock = async (parentBlockId) => {
const allBlocks = [];
let hasMore = true;
let startCursor;
while (hasMore) {
await semaphore.acquire();
try {
const response = await this.getBlockChildren(parentBlockId, startCursor);
allBlocks.push(...response.results);
hasMore = response.has_more;
startCursor = response.next_cursor ?? undefined;
}
finally {
semaphore.release();
}
}
return allBlocks;
};
// Breadth-first traversal - fetch level by level
const allBlocks = [];
let currentLevelBlocks = [{ id: blockId, has_children: true }];
let level = 0;
while (currentLevelBlocks.length > 0) {
level++;
// Fetch children for all blocks at current level in parallel
const fetchPromises = currentLevelBlocks
.filter(block => block.has_children)
.map(async (block) => {
const children = await fetchAllPagesForBlock(block.id);
return { parentId: block.id, children };
});
const levelResults = await Promise.all(fetchPromises);
// Collect all children for the next level
const nextLevelBlocks = [];
for (const { parentId, children } of levelResults) {
// Don't add to allBlocks if this is the root page call
if (level > 1 || parentId !== blockId) {
allBlocks.push(...children);
}
else {
// For root level, just add to allBlocks without the page itself
allBlocks.push(...children);
}
// Add children with children to next level
nextLevelBlocks.push(...children.filter(child => child.has_children));
// Attach children to their parent blocks (for structure preservation)
if (level > 1) {
const parentBlock = allBlocks.find(b => b.id === parentId);
if (parentBlock) {
parentBlock.children = children;
}
}
else {
// For level 1, attach children to blocks in allBlocks
for (const block of allBlocks) {
if (block.id === parentId) {
block.children = children;
}
}
}
}
currentLevelBlocks = nextLevelBlocks;
}
return allBlocks;
}
// Helper method to create rich text (needed for comments)
createRichText(content, annotations, link) {
return {
type: 'text',
text: {
content,
link: link !== undefined ? { url: link } : null,
},
annotations: {
bold: false,
italic: false,
strikethrough: false,
underline: false,
code: false,
color: 'default',
...annotations,
},
plain_text: content,
};
}
// ========================================
// HIGH-LEVEL METHODS FOR MCP TOOLS
// ========================================
/**
* Create a Notion page from markdown content
*/
async createPageFromMarkdown(databaseId, options) {
const debug = process.env.NODE_ENV === 'development';
try {
let markdown;
let { pageTitle } = options;
// Determine which input to use
if (options.markdown !== undefined && options.filePath !== undefined) {
throw new Error('Cannot provide both markdown content and filePath. Please provide only one.');
}
if (options.markdown !== undefined) {
markdown = options.markdown;
}
else if (options.filePath !== undefined) {
// Read file and extract title if not provided
markdown = await readMarkdownFile(options.filePath);
if (pageTitle === undefined) {
const filename = basename(options.filePath, '.md');
pageTitle = filename.replace(/[-_]/g, ' ').replace(/\b\w/g, (l) => l.toUpperCase());
}
}
else {
throw new Error('Either markdown content or filePath must be provided.');
}
// Convert markdown to blocks using utility function
const conversionResult = markdownToNotion(markdown, options.conversionOptions ?? {});
// Conversion complete - warnings are handled internally
const blocks = conversionResult.content;
// Extract title from markdown if not provided
if (pageTitle === undefined) {
const extractedTitle = extractTitleFromMarkdown(markdown);
pageTitle = extractedTitle ?? 'Untitled';
}
// Get the correct title property name for this database
const titlePropertyName = await this.getTitlePropertyName(databaseId);
// Build page properties
let properties = {
[titlePropertyName]: {
type: 'title',
title: [
{
type: 'text',
text: {
content: pageTitle ?? 'Untitled',
},
},
],
},
};
// Ensure database has required properties if metadata is provided
if (options.metadata !== undefined && Object.keys(options.metadata).length > 0) {
await this.ensureDatabaseProperties(databaseId);
}
// Smart property setting for all metadata
if (options.metadata !== undefined) {
// Handle description
if (options.metadata.description !== undefined) {
await this.setPropertyValue(properties, 'Description', options.metadata.description, databaseId);
}
// Handle category with smart detection
if (options.metadata.category !== undefined) {
await this.setPropertyValue(properties, 'Category', options.metadata.category, databaseId);
}
// Handle tags
if (options.metadata.tags !== undefined && Array.isArray(options.metadata.tags)) {
await this.setPropertyValue(properties, 'Tags', options.metadata.tags, databaseId);
}
// Handle status
if (options.metadata.status !== undefined) {
await this.setPropertyValue(properties, 'Status', options.metadata.status, databaseId);
}
}
// Create the page with retry logic for property type mismatches
let page;
let createdPageId = null;
let retryCount = 0;
const maxRetries = 1;
while (retryCount <= maxRetries) {
try {
page = await this.createPage({
parent: { type: 'database_id', database_id: databaseId },
properties,
});
createdPageId = page.id;
if (debug) {
// Page created successfully with ID: page.id
}
break; // Success, exit loop
}
catch (error) {
if (retryCount < maxRetries &&
error instanceof Error &&
error.message?.includes('is expected to be')) {
// Property type mismatch detected, clearing cache and retrying
this.propertyTypeCache.delete(databaseId);
retryCount++;
// Rebuild properties with fresh type detection
properties = {
[titlePropertyName]: {
type: 'title',
title: [
{
type: 'text',
text: {
content: pageTitle ?? 'Untitled',
},
},
],
},
};
// Re-apply metadata with fresh detection
if (options.metadata !== undefined) {
if (options.metadata.description !== undefined) {
await this.setPropertyValue(properties, 'Description', options.metadata.description, databaseId);
}
if (options.metadata.category !== undefined) {
await this.setPropertyValue(properties, 'Category', options.metadata.category, databaseId);
}
if (options.metadata.tags !== undefined && Array.isArray(options.metadata.tags)) {
await this.setPropertyValue(properties, 'Tags', options.metadata.tags, databaseId);
}
if (options.metadata.status !== undefined) {
await this.setPropertyValue(properties, 'Status', options.metadata.status, databaseId);
}
}
}
else {
// If we created a page but failed, clean it up
if (createdPageId !== null) {
let cleanupSuccessful = false;
try {
await this.archivePage(createdPageId);
cleanupSuccessful = true;
// Successfully cleaned up orphaned page after creation failure
}
catch {
// Failed to cleanup orphaned page - manual cleanup may be required
}
// Enhance error message with cleanup status
if (error instanceof Error) {
if (cleanupSuccessful) {
error.message += '\n\n✅ Cleanup: The partially created page has been archived.';
}
else {
error.message += `\n\n⚠️ Note: A partially created page may remain in your database. Page ID: ${createdPageId}`;
}
}
}
throw error; // Re-throw if not a type mismatch or max retries reached
}
}
}
// Add blocks to the page if any, using chunked method for large documents
try {
if (blocks.length > 0) {
await this.appendBlockChildrenChunked(page.id, blocks);
}
}
catch (error) {
// If block addition fails, clean up the page
let cleanupSuccessful = false;
try {
await this.archivePage(page.id);
cleanupSuccessful = true;
// Successfully cleaned up orphaned page after block creation failure
}
catch {
// Failed to cleanup orphaned page - manual cleanup may be required
}
// Enhance error message based on failure type and cleanup result
if (error instanceof Error) {
let enhancedMessage = error.message;
if (error.message.includes('block_validation_error') || error.message.includes('should be ≤')) {
enhancedMessage = `❌ Block validation failed: ${error.message}`;
}
else if (error.message.includes('rate_limited')) {
enhancedMessage = `⏱️ Rate limit exceeded: ${error.message}`;
}
if (cleanupSuccessful) {
enhancedMessage += '\n\n✅ Cleanup: The partially created page has been archived.';
}
else {
enhancedMessage += `\n\n⚠️ Note: A partially created page may remain in your database. Page ID: ${page.id}`;
}
error.message = enhancedMessage;
}
throw error;
}
// Page created successfully with smart property detection
return { page: page, conversionResult };
}
catch (error) {
throw new Error(`Failed to create page from markdown: ${String(error)}`);
}
}
/**
* Export a Notion page to markdown
*/
async exportPageToMarkdown(pageId, options = {}) {
try {
// Get the page details
const page = await this.getPage(pageId);
// Get all page blocks using optimized breadth-first parallel fetching
const blocks = await this.getAllBlocksRecursivelyParallel(pageId, 8);
// Convert to markdown using utility function
const conversionResult = notionToMarkdown(blocks, options);
const markdown = conversionResult.content;
return { markdown, page, conversionResult };
}
catch (error) {
throw new Error(`Failed to export page to markdown: ${String(error)}`);
}
}
/**
* Update page metadata (properties only)
*/
async updatePageMetadata(pageId, metadata) {
try {
// Build update properties
const properties = {};
if (metadata.category !== undefined) {
properties.Category = {
select: { name: metadata.category },
};
}
if (metadata.tags !== undefined) {
properties.Tags = {
multi_select: metadata.tags.map(tag => ({ name: tag })),
};
}
if (metadata.description !== undefined) {
properties.Description = {
rich_text: [
{
text: { content: metadata.description },
},
],
};
}
if (metadata.status !== undefined) {
properties.Status = {
select: { name: metadata.status },
};
}
return await this.updatePage(pageId, { properties });
}
catch (error) {
throw new Error(`Failed to update page metadata: ${String(error)}`);
}
}
/**
* Update page content (create new page, keep old page)
*/
async updatePageContent(pageId, options) {
try {
// Get markdown content
let markdown;
if (options.markdown === undefined && options.filePath === undefined) {
throw new Error('Either markdown content or filePath must be provided');
}
if (options.markdown !== undefined && options.filePath !== undefined) {
throw new Error('Provide either markdown content or filePath, not both');
}
if (options.filePath !== undefined) {
if (!validateFilePath(options.filePath) || !options.filePath.endsWith('.md')) {
throw new Error(`Invalid file path: ${options.filePath}. Must be a .md file with valid path.`);
}
markdown = await readMarkdownFile(options.filePath);
}
else {
markdown = options.markdown;
}
// Get current page to preserve metadata
const currentPage = await this.getPage(pageId);
// Extract current metadata
const properties = currentPage.properties;
const _pageTitle = extractPageTitle(currentPage);
// Get parent database
const parent = currentPage.parent;
if (parent.type !== 'database_id') {
throw new Error('Can only update pages that are in a database');
}
// Convert markdown to blocks
const conversionResult = markdownToNotion(markdown, options.conversionOptions);
const blocks = conversionResult.content;
// Create new page with same properties
const newPage = await this.createPage({
parent: { type: 'database_id', database_id: parent.database_id },
properties: properties,
});
// Add blocks to new page using chunked method for large documents
if (blocks.length > 0) {
await this.appendBlockChildrenChunked(newPage.id, blocks);
}
// Archive the old page to complete the replacement
await this.archivePage(pageId);
return { conversionResult, newPageId: newPage.id };
}
catch (error) {
throw new Error(`Failed to update page content: ${String(error)}`);
}
}
/**
* List/query database pages with advanced filtering and sorting
*/
async listDatabasePages(databaseId, options = {}) {
try {
const { limit = 10, search, category, tags, status, sortBy = 'last_edited', sortOrder = 'descending', startCursor, searchMode = 'tags', } = options;
// Get the title property name for this database
const titlePropertyName = await this.getTitlePropertyName(databaseId);
// Build filter conditions
const filters = [];
// Text search based on searchMode
if (search !== undefined) {
if (searchMode === 'tags') {
// Search only in tags
filters.push({
property: 'Tags',
multi_select: {
contains: search,
},
});
}
else if (searchMode === 'full-text') {
// Search in title and description (current behavior)
filters.push({
or: [
{
property: titlePropertyName,
title: {
contains: search,
},
},
{
property: 'Description',
rich_text: {
contains: search,
},
},
],
});
}
else if (searchMode === 'combined') {
// Search everywhere: tags, title, and description
filters.push({
or: [
{
property: 'Tags',
multi_select: {
contains: search,
},
},
{
property: titlePropertyName,
title: {
contains: search,
},
},
{
property: 'Description',
rich_text: {
contains: search,
},
},
],
});
}
}
// Category filter
if (category !== undefined) {
filters.push({
property: 'Category',
select: {
equals: category,
},
});
}
// Status filter
if (status !== undefined) {
filters.push({
property: 'Status',
select: {
equals: status,
},
});
}
// Tags filter (any of the specified tags)
if (tags && tags.length > 0) {
if (tags.length === 1) {
filters.push({
property: 'Tags',
multi_select: {
contains: tags[0],
},
});
}
else {
// Multiple tags - find pages that contain any of these tags
filters.push({
or: tags.map(tag => ({
property: 'Tags',
multi_select: {
contains: tag,
},
})),
});
}
}
// Build final filter
let filter = undefined;
if (filters.length === 1) {
filter = filters[0];
}
else if (filters.length > 1) {
filter = { and: filters };
}
// Build sort configuration
const sorts = [];
switch (sortBy) {
case 'title':
sorts.push({
property: titlePropertyName,
direction: sortOrder,
});
break;
case 'category':
sorts.push({
property: 'Category',
direction: sortOrder,
});
break;
case 'status':
sorts.push({
property: 'Status',
direction: sortOrder,
});
break;
case 'created':
sorts.push({
timestamp: 'created_time',
direction: sortOrder,
});
break;
case 'last_edited':
default:
sorts.push({
timestamp: 'last_edited_time',
direction: sortOrder,
});
break;
}
// Execute query
const queryRequest = {
database_id: databaseId,
page_size: Math.min(limit, 100), // Notion API max is 100
};
if (filter) {
queryRequest.filter = filter;
}
if (sorts.length > 0) {
queryRequest.sorts = sorts;
}
if (startCursor !== undefined) {
queryRequest.start_cursor = startCursor;
}
return await this.queryDatabase(queryRequest);
}
catch (error) {
throw new Error(`Failed to query database pages: ${String(error)}`);
}
}
/**
* Enhanced version of listDatabasePages that includes search match metadata
*/
async searchPagesWithMetadata(databaseId, options = {}) {
const startTime = Date.now();
const searchMode = options.searchMode ?? 'tags';
const searchTerm = options.search ?? '';
// Get the regular results
const response = await this.listDatabasePages(databaseId, options);
// Analyze results
const enhancedResults = [];
const stats = {
totalResults: 0,
resultsByLocation: { tags: 0, title: 0, description: 0, content: 0 },
searchMode: searchMode,
searchTerm: searchTerm,
executionTime: 0,
};
// Process each result to add metadata
for (const page of response.results) {
let metadata;
if (searchTerm !== '') {
metadata = this.analyzeSearchMatch(page, searchTerm, searchMode);
}
else {
// No search term - default metadata
metadata = {
searchMode: searchMode,
matchLocation: 'content',
matchedTerms: [],
};
}
enhancedResults.push({ page, metadata });
stats.totalResults++;
stats.resultsByLocation[metadata.matchLocation]++;
}
stats.executionTime = Date.now() - startTime;
return {
results: enhancedResults,
statistics: stats,
hasMore: response.has_more,
nextCursor: response.next_cursor ?? undefined,
};
}
/**
* Find the title property name in a database
*/
async getTitlePropertyName(databaseId) {
try {
const database = await this.getDatabase(databaseId);
const properties = database.properties;
// Find the property with type "title"
for (const [propertyName, property] of Object.entries(properties)) {
if (property.type === 'title') {
return propertyName;
}
}
// Fallback to common names if no title property found
return 'title';
}
catch {
// Could not determine title property name, using fallback
return 'title';
}
}
/**
* Ensure database has required properties for metadata
*/
async ensureDatabaseProperties(databaseId) {
try {
const database = await this.getDatabase(databaseId);
const existingProperties = database.properties;
const propertiesToCreate = {};
// Helper to check if property exists with correct type
const needsProperty = (name, expectedType) => {
return existingProperties[name] === undefined || existingProperties[name].type !== expectedType;
};
// Only add properties that don't exist or have wrong type
if (needsProperty('Description', 'rich_text')) {
propertiesToCreate['Description'] = { rich_text: {} };
}
if (needsProperty('Tags', 'multi_select')) {
propertiesToCreate['Tags'] = { multi_select: {} };
}
if (needsProperty('Status', 'select')) {
propertiesToCreate['Status'] = {
select: {
options: [
{ name: 'published', color: 'green' },
{ name: 'draft', color: 'yellow' },
{ name: 'archived', color: 'gray' },
{ name: 'review', color: 'blue' },
],
},
};
}
// Don't create Category - respect existing configuration
// Category property handling is done through smart detection
// Only update if there are properties to add
if (Object.keys(propertiesToCreate).length > 0) {
await this.updateDatabase(databaseId, {
properties: propertiesToCreate,
});
// Database properties updated successfully
}
}
catch {
// Failed to ensure database properties, continue with existing schema
}
}
}
//# sourceMappingURL=notion.js.map