@moxn/kb-migrate 0.4.24 → 0.4.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.ts CHANGED
@@ -53,7 +53,7 @@ export declare class MoxnClient {
53
53
  */
54
54
  addDatabaseColumn(databaseId: string, input: {
55
55
  name: string;
56
- type: 'select' | 'multi_select' | 'status' | 'checkbox' | 'number' | 'date' | 'text' | 'url' | 'email';
56
+ type: 'select' | 'multi_select' | 'status' | 'checkbox' | 'number' | 'date' | 'text' | 'url' | 'email' | 'page_ref';
57
57
  optionTagIds?: string[];
58
58
  newOptionParentPath?: string;
59
59
  config?: Record<string, unknown>;
@@ -176,5 +176,26 @@ export declare class MoxnClient {
176
176
  * Set a scalar property value on a document in a database.
177
177
  */
178
178
  setPropertyValue(databaseId: string, documentId: string, columnName: string, value: string | number | boolean | null): Promise<void>;
179
+ /**
180
+ * Create or upsert a Notion page → KB document mapping.
181
+ */
182
+ createNotionPageMapping(input: {
183
+ kbDocumentId: string;
184
+ notionPageId: string;
185
+ importSource: 'import' | 'export';
186
+ notionPageTitle?: string;
187
+ }): Promise<void>;
188
+ /**
189
+ * Get all Notion database → KB database mappings for the tenant.
190
+ * Returns a Map of notionDatabaseId → kbDatabaseId.
191
+ */
192
+ getAllDatabaseMappings(): Promise<Map<string, string>>;
193
+ /**
194
+ * Get a KB database by ID. Returns null if not found (404).
195
+ */
196
+ getDatabase(databaseId: string): Promise<{
197
+ id: string;
198
+ name: string;
199
+ } | null>;
179
200
  private isConflictError;
180
201
  }
package/dist/client.js CHANGED
@@ -522,6 +522,52 @@ export class MoxnClient {
522
522
  throw new Error(body.error || `Failed to set property value: ${response.status}`);
523
523
  }
524
524
  }
525
+ // ──────────────────────────────────────────────
526
+ // Notion page mapping methods
527
+ // ──────────────────────────────────────────────
528
+ /**
529
+ * Create or upsert a Notion page → KB document mapping.
530
+ */
531
+ async createNotionPageMapping(input) {
532
+ const response = await fetch(`${this.apiUrl}/api/v1/kb/notion-mappings`, {
533
+ method: 'POST',
534
+ headers: {
535
+ 'Content-Type': 'application/json',
536
+ 'x-api-key': this.apiKey,
537
+ },
538
+ body: JSON.stringify(input),
539
+ });
540
+ if (!response.ok) {
541
+ const body = await response.json().catch(() => ({}));
542
+ throw new Error(body.error || `Failed to create page mapping: ${response.status}`);
543
+ }
544
+ }
545
+ /**
546
+ * Get all Notion database → KB database mappings for the tenant.
547
+ * Returns a Map of notionDatabaseId → kbDatabaseId.
548
+ */
549
+ async getAllDatabaseMappings() {
550
+ const response = await fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases`, {
551
+ headers: { 'x-api-key': this.apiKey },
552
+ });
553
+ if (!response.ok) {
554
+ const body = await response.json().catch(() => ({}));
555
+ throw new Error(body.error || `Failed to get database mappings: ${response.status}`);
556
+ }
557
+ const data = await response.json();
558
+ const map = new Map();
559
+ for (const mapping of data.mappings) {
560
+ map.set(mapping.notionDatabaseId, mapping.kbDatabaseId);
561
+ }
562
+ return map;
563
+ }
564
+ /**
565
+ * Get a KB database by ID. Returns null if not found (404).
566
+ */
567
+ async getDatabase(databaseId) {
568
+ const databases = await this.listDatabases();
569
+ return databases.find((db) => db.id === databaseId) ?? null;
570
+ }
525
571
  isConflictError(error) {
526
572
  return (error instanceof Error &&
527
573
  'documentId' in error &&
package/dist/index.js CHANGED
@@ -16,6 +16,8 @@ import { Command } from 'commander';
16
16
  import { LocalSource } from './sources/local.js';
17
17
  import { NotionSource } from './sources/notion.js';
18
18
  import { notionColorToHex } from './sources/notion-api.js';
19
+ import { getPageTitle } from './sources/notion-blocks.js';
20
+ import { slugify } from './sources/notion.js';
19
21
  import { MoxnClient } from './client.js';
20
22
  import { runExport } from './export.js';
21
23
  import { runNotionExport } from './export-notion.js';
@@ -42,6 +44,21 @@ async function runMigration(source, options) {
42
44
  console.log(`Processing: ${doc.sourcePath}${progress}`);
43
45
  const result = await client.migrateDocument(doc, options.basePath, options.onConflict, options.dryRun);
44
46
  results.push(result);
47
+ // Store Notion page → KB document mapping (fire-and-forget)
48
+ if (result.sourcePageId &&
49
+ result.documentId &&
50
+ (result.status === 'created' || result.status === 'updated')) {
51
+ client
52
+ .createNotionPageMapping({
53
+ kbDocumentId: result.documentId,
54
+ notionPageId: result.sourcePageId,
55
+ importSource: 'import',
56
+ notionPageTitle: doc.metadata?.notionTitle,
57
+ })
58
+ .catch((err) => {
59
+ console.warn(` Warning: page mapping failed for ${result.sourcePageId}: ${err instanceof Error ? err.message : err}`);
60
+ });
61
+ }
45
62
  // Track consecutive failures for circuit breaker
46
63
  if (result.status === 'failed') {
47
64
  consecutiveFailures++;
@@ -63,12 +80,15 @@ async function runMigration(source, options) {
63
80
  // Circuit breaker: abort after too many consecutive failures
64
81
  if (consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
65
82
  console.error(`\nAborting: ${MAX_CONSECUTIVE_FAILURES} consecutive failures. ` +
66
- 'Last error: ' + (result.error || 'unknown') + '\n' +
83
+ 'Last error: ' +
84
+ (result.error || 'unknown') +
85
+ '\n' +
67
86
  'Fix the underlying issue and retry. Remaining documents will be skipped.');
68
87
  break;
69
88
  }
70
89
  }
71
- // Build summary
90
+ // Build summary (include extraction stats if available)
91
+ const extractionStats = source.getExtractionStats?.();
72
92
  const summary = {
73
93
  total: results.length,
74
94
  created: results.filter((r) => r.status === 'created').length,
@@ -76,6 +96,12 @@ async function runMigration(source, options) {
76
96
  skipped: results.filter((r) => r.status === 'skipped').length,
77
97
  failed: results.filter((r) => r.status === 'failed').length,
78
98
  duration: Date.now() - startTime,
99
+ ...(extractionStats && {
100
+ discovered: extractionStats.discovered,
101
+ skippedDuringExtraction: extractionStats.skippedEmpty.length +
102
+ extractionStats.skippedNoContent.length +
103
+ extractionStats.errors.length,
104
+ }),
79
105
  };
80
106
  const log = {
81
107
  timestamp: new Date().toISOString(),
@@ -101,6 +127,15 @@ function printSummary(log) {
101
127
  console.log(`Base path: ${log.basePath}`);
102
128
  console.log(`Duration: ${(log.summary.duration / 1000).toFixed(1)}s`);
103
129
  console.log('');
130
+ // Show reconciliation if extraction stats are available
131
+ if (log.summary.discovered != null && log.summary.skippedDuringExtraction != null) {
132
+ console.log(`Discovered: ${log.summary.discovered} pages`);
133
+ if (log.summary.skippedDuringExtraction > 0) {
134
+ console.log(`Skipped: ${log.summary.skippedDuringExtraction} (empty or errored during extraction)`);
135
+ }
136
+ console.log(`Processed: ${log.summary.total}`);
137
+ console.log('');
138
+ }
104
139
  console.log(`Total: ${log.summary.total}`);
105
140
  console.log(`Created: ${log.summary.created}`);
106
141
  console.log(`Updated: ${log.summary.updated}`);
@@ -432,9 +467,20 @@ program
432
467
  if (!opts.dryRun && dbImports.length > 0) {
433
468
  console.log(`\nPre-creating ${dbImports.length} database(s)...`);
434
469
  const preClient = new MoxnClient(migrationOptions);
470
+ // Fetch existing database mappings (1 API call) to avoid duplicates
471
+ let existingMappings = new Map();
472
+ try {
473
+ existingMappings = await preClient.getAllDatabaseMappings();
474
+ if (existingMappings.size > 0) {
475
+ console.log(` Found ${existingMappings.size} existing database mapping(s).`);
476
+ }
477
+ }
478
+ catch (error) {
479
+ console.warn(` Warning: Could not fetch existing mappings: ${error instanceof Error ? error.message : error}`);
480
+ }
435
481
  for (const dbImport of dbImports) {
436
482
  try {
437
- const kbDbId = await preCreateNotionDatabase(preClient, dbImport);
483
+ const kbDbId = await preCreateNotionDatabase(preClient, dbImport, existingMappings);
438
484
  databaseIdMap.set(dbImport.notionDatabaseId, kbDbId);
439
485
  }
440
486
  catch (error) {
@@ -443,10 +489,77 @@ program
443
489
  }
444
490
  // Make the map available during extraction
445
491
  source.setDatabaseIdMap(databaseIdMap);
446
- console.log(` ${databaseIdMap.size} database(s) pre-created for embed resolution.\n`);
492
+ console.log(` ${databaseIdMap.size} database(s) ready for embed resolution.\n`);
447
493
  }
448
494
  // Step 3: Run page migration (validate is idempotent, extract uses databaseIdMap)
449
495
  const log = await runMigration(source, migrationOptions);
496
+ // Step 3b: Resolve inline databases discovered during extraction
497
+ if (!opts.dryRun) {
498
+ const unresolvedDbs = source.getUnresolvedInlineDatabases();
499
+ if (unresolvedDbs.size > 0) {
500
+ console.log(`\nResolving ${unresolvedDbs.size} inline database(s) discovered during extraction...`);
501
+ const inlineClient = new MoxnClient(migrationOptions);
502
+ const notionApi = source.getNotionApiClient();
503
+ // Fetch current mappings to avoid duplicates (may have changed since pre-create)
504
+ let currentMappings = new Map();
505
+ try {
506
+ currentMappings = await inlineClient.getAllDatabaseMappings();
507
+ }
508
+ catch {
509
+ // Non-fatal — will create new databases
510
+ }
511
+ let inlineCreated = 0;
512
+ for (const [notionDbId, title] of unresolvedDbs) {
513
+ // Skip if already mapped (could happen if previously imported)
514
+ if (databaseIdMap.has(notionDbId) || currentMappings.has(notionDbId)) {
515
+ continue;
516
+ }
517
+ try {
518
+ // Fetch database schema from Notion
519
+ const notionDb = await notionApi.getDatabase(notionDbId);
520
+ const { parseDatabaseSchema } = await import('./sources/notion-databases.js');
521
+ const schema = parseDatabaseSchema(notionDb);
522
+ // Create KB database
523
+ const kbDb = await inlineClient.createDatabase({
524
+ name: schema.name || title,
525
+ description: schema.description || undefined,
526
+ });
527
+ // Store mapping
528
+ try {
529
+ await inlineClient.createNotionDatabaseMapping({
530
+ kbDatabaseId: kbDb.id,
531
+ notionDatabaseId: notionDbId,
532
+ notionDatabaseTitle: schema.name || title,
533
+ });
534
+ }
535
+ catch {
536
+ // Non-fatal — database created but mapping may fail
537
+ }
538
+ databaseIdMap.set(notionDbId, kbDb.id);
539
+ // Query entries and add to dbImports for finalization
540
+ const entries = await notionApi.queryDatabase(notionDbId);
541
+ if (entries.length > 0) {
542
+ dbImports.push({
543
+ notionDatabaseId: notionDbId,
544
+ schema,
545
+ entries: entries.map((entry) => {
546
+ const entryTitle = getPageTitle(entry);
547
+ return { page: entry, kbPath: slugify(entryTitle) };
548
+ }),
549
+ });
550
+ }
551
+ inlineCreated++;
552
+ console.log(` Created inline database: ${schema.name || title} (${kbDb.id}) — ${entries.length} entries`);
553
+ }
554
+ catch (error) {
555
+ console.warn(` Warning: Failed to create inline database "${title}" (${notionDbId}): ${error instanceof Error ? error.message : error}`);
556
+ }
557
+ }
558
+ if (inlineCreated > 0) {
559
+ console.log(` Created ${inlineCreated} inline database(s). Re-run with --on-conflict update to resolve embeds.`);
560
+ }
561
+ }
562
+ }
450
563
  // Step 4: Resolve cross-references (between page migration and database finalization)
451
564
  if (!opts.dryRun) {
452
565
  const refsWithData = log.results.filter((r) => r.references?.length && r.sectionIds?.length);
@@ -581,15 +694,26 @@ program
581
694
  * Called before document extraction so child_database blocks can resolve.
582
695
  * Returns the KB database ID.
583
696
  */
584
- async function preCreateNotionDatabase(client, dbImport) {
697
+ async function preCreateNotionDatabase(client, dbImport, existingMappings) {
585
698
  const { schema } = dbImport;
699
+ // Check if we already have a mapping for this Notion database
700
+ const existingKbDbId = existingMappings.get(dbImport.notionDatabaseId);
701
+ if (existingKbDbId) {
702
+ // Verify the KB database still exists
703
+ const existingDb = await client.getDatabase(existingKbDbId);
704
+ if (existingDb) {
705
+ console.log(` Reusing existing database: ${existingDb.name} (${existingKbDbId})`);
706
+ return existingKbDbId;
707
+ }
708
+ console.log(` Mapped database ${existingKbDbId} no longer exists, creating new one.`);
709
+ }
586
710
  console.log(` Pre-creating database: ${schema.name}`);
587
711
  const db = await client.createDatabase({
588
712
  name: schema.name,
589
713
  description: schema.description || undefined,
590
714
  });
591
715
  console.log(` Database created: ${db.id}`);
592
- // Store Notion → KB database mapping
716
+ // Store Notion → KB database mapping (upsert handles duplicates)
593
717
  try {
594
718
  await client.createNotionDatabaseMapping({
595
719
  kbDatabaseId: db.id,
@@ -688,6 +812,8 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
688
812
  // 3. Link entries and assign tags
689
813
  // Build a map of KB path → { documentId, branchId } from migration results
690
814
  const docByPath = new Map();
815
+ // Build a map of Notion page ID → KB document ID for page_ref resolution
816
+ const notionIdToDocId = new Map();
691
817
  for (const result of log.results) {
692
818
  if (result.documentId && result.branchId) {
693
819
  docByPath.set(result.documentPath, {
@@ -695,6 +821,9 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
695
821
  branchId: result.branchId,
696
822
  });
697
823
  }
824
+ if (result.sourcePageId && result.documentId) {
825
+ notionIdToDocId.set(result.sourcePageId, result.documentId);
826
+ }
698
827
  }
699
828
  let linkedCount = 0;
700
829
  for (const entry of entries) {
@@ -749,6 +878,19 @@ async function importNotionDatabase(client, dbImport, log, options, preCreatedDb
749
878
  case 'email':
750
879
  value = scalarVal.textValue ?? null;
751
880
  break;
881
+ case 'page_ref': {
882
+ // Resolve notion:XXX markers to KB document IDs
883
+ const refs = JSON.parse(scalarVal.textValue ?? '[]');
884
+ const resolvedRefs = refs.map((ref) => {
885
+ if (ref.startsWith('notion:')) {
886
+ const notionId = ref.substring(7);
887
+ return notionIdToDocId.get(notionId) ?? ref;
888
+ }
889
+ return ref;
890
+ });
891
+ value = JSON.stringify(resolvedRefs);
892
+ break;
893
+ }
752
894
  }
753
895
  if (value != null) {
754
896
  try {
@@ -40,4 +40,15 @@ export declare abstract class MigrationSource<TConfig extends SourceConfig = Sou
40
40
  * Returns undefined if count cannot be determined without full extraction
41
41
  */
42
42
  abstract getDocumentCount(): Promise<number | undefined>;
43
+ /**
44
+ * Get extraction stats for reconciling discovered vs extracted counts.
45
+ * Only available after extract() completes.
46
+ */
47
+ getExtractionStats?(): {
48
+ discovered: number;
49
+ extracted: number;
50
+ skippedEmpty: string[];
51
+ skippedNoContent: string[];
52
+ errors: string[];
53
+ };
43
54
  }
@@ -16,6 +16,8 @@ export type UnsupportedBlockTracker = Map<string, number>;
16
16
  * H2 headings create section boundaries.
17
17
  * Content before the first H2 goes into an "Introduction" section.
18
18
  */
19
+ /** Collects Notion database IDs that were encountered but not in databaseIdMap. */
20
+ export type UnresolvedChildDatabaseCollector = Map<string, string>;
19
21
  export declare function blocksToSections(blocks: NotionBlock[], client: NotionApiClient, pagePathMap: PagePathMap, options?: {
20
22
  /** Track synced block IDs to detect cycles. */
21
23
  visitedSyncedBlocks?: Set<string>;
@@ -23,6 +25,8 @@ export declare function blocksToSections(blocks: NotionBlock[], client: NotionAp
23
25
  databaseIdMap?: Map<string, string>;
24
26
  /** Accumulates counts of unsupported block types (caller-owned). */
25
27
  unsupportedBlocks?: UnsupportedBlockTracker;
28
+ /** Collects inline databases not yet mapped (caller-owned). */
29
+ unresolvedChildDatabases?: UnresolvedChildDatabaseCollector;
26
30
  }): Promise<SectionInput[]>;
27
31
  /** Convert rich text array to markdown string. */
28
32
  export declare function richTextToMarkdown(richText: NotionRichText[]): string;
@@ -4,19 +4,11 @@
4
4
  * Section boundaries are created at H2 headings.
5
5
  * Rich text is converted to markdown.
6
6
  */
7
- // ============================================
8
- // Main entry point
9
- // ============================================
10
- /**
11
- * Convert a page's blocks into Moxn sections.
12
- *
13
- * H2 headings create section boundaries.
14
- * Content before the first H2 goes into an "Introduction" section.
15
- */
16
7
  export async function blocksToSections(blocks, client, pagePathMap, options) {
17
8
  const visitedSyncedBlocks = options?.visitedSyncedBlocks ?? new Set();
18
9
  const databaseIdMap = options?.databaseIdMap;
19
10
  const unsupportedBlocks = options?.unsupportedBlocks;
11
+ const unresolvedChildDatabases = options?.unresolvedChildDatabases;
20
12
  const sections = [];
21
13
  let currentSectionName = 'Introduction';
22
14
  let currentBlocks = [];
@@ -36,7 +28,7 @@ export async function blocksToSections(blocks, client, pagePathMap, options) {
36
28
  continue;
37
29
  }
38
30
  // Convert block to content blocks
39
- const converted = await convertBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks);
31
+ const converted = await convertBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases);
40
32
  currentBlocks.push(...converted);
41
33
  }
42
34
  // Flush last section
@@ -52,7 +44,7 @@ export async function blocksToSections(blocks, client, pagePathMap, options) {
52
44
  // ============================================
53
45
  // Block conversion
54
46
  // ============================================
55
- async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
47
+ async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases) {
56
48
  const results = [];
57
49
  switch (block.type) {
58
50
  case 'paragraph':
@@ -82,10 +74,10 @@ async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, dat
82
74
  results.push(...convertToDo(block));
83
75
  break;
84
76
  case 'quote':
85
- results.push(...(await convertQuote(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks)));
77
+ results.push(...(await convertQuote(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases)));
86
78
  break;
87
79
  case 'callout':
88
- results.push(...(await convertCallout(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks)));
80
+ results.push(...(await convertCallout(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases)));
89
81
  break;
90
82
  case 'divider':
91
83
  results.push(textBlock('---'));
@@ -94,7 +86,7 @@ async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, dat
94
86
  results.push(...(await convertTable(block, client)));
95
87
  break;
96
88
  case 'toggle':
97
- results.push(...(await convertToggle(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks)));
89
+ results.push(...(await convertToggle(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases)));
98
90
  break;
99
91
  case 'bookmark':
100
92
  results.push(...convertBookmark(block));
@@ -120,13 +112,13 @@ async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, dat
120
112
  // Just note it in the content.
121
113
  break;
122
114
  case 'child_database':
123
- results.push(...convertChildDatabase(block, databaseIdMap));
115
+ results.push(...convertChildDatabase(block, databaseIdMap, unresolvedChildDatabases));
124
116
  break;
125
117
  case 'synced_block':
126
- results.push(...(await convertSyncedBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks)));
118
+ results.push(...(await convertSyncedBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases)));
127
119
  break;
128
120
  case 'column_list':
129
- results.push(...(await convertColumnList(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks)));
121
+ results.push(...(await convertColumnList(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases)));
130
122
  break;
131
123
  case 'column':
132
124
  // Columns are handled by column_list
@@ -169,7 +161,7 @@ async function convertBlock(block, client, pagePathMap, visitedSyncedBlocks, dat
169
161
  ? ' '
170
162
  : undefined;
171
163
  const children = await client.getBlockChildren(block.id);
172
- const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, indent, databaseIdMap, unsupportedBlocks);
164
+ const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, indent, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases);
173
165
  results.push(...childBlocks);
174
166
  }
175
167
  return results;
@@ -215,7 +207,7 @@ function convertToDo(block) {
215
207
  const text = richTextToMarkdown(td.to_do.rich_text);
216
208
  return [textBlock(`${checkbox} ${text}`)];
217
209
  }
218
- async function convertQuote(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
210
+ async function convertQuote(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases) {
219
211
  const q = block;
220
212
  const text = richTextToMarkdown(q.quote.rich_text);
221
213
  if (!text && !block.has_children)
@@ -229,12 +221,12 @@ async function convertQuote(block, client, pagePathMap, visitedSyncedBlocks, dat
229
221
  const results = quoted ? [textBlock(quoted)] : [];
230
222
  if (block.has_children) {
231
223
  const children = await client.getBlockChildren(block.id);
232
- const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap, unsupportedBlocks);
224
+ const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap, unsupportedBlocks, unresolvedChildDatabases);
233
225
  results.push(...childBlocks);
234
226
  }
235
227
  return results;
236
228
  }
237
- async function convertCallout(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
229
+ async function convertCallout(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases) {
238
230
  const c = block;
239
231
  const text = richTextToMarkdown(c.callout.rich_text);
240
232
  const emoji = c.callout.icon?.emoji ?? '';
@@ -246,7 +238,7 @@ async function convertCallout(block, client, pagePathMap, visitedSyncedBlocks, d
246
238
  const results = [textBlock(quoted)];
247
239
  if (block.has_children) {
248
240
  const children = await client.getBlockChildren(block.id);
249
- const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap, unsupportedBlocks);
241
+ const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap, unsupportedBlocks, unresolvedChildDatabases);
250
242
  results.push(...childBlocks);
251
243
  }
252
244
  return results;
@@ -275,18 +267,18 @@ async function convertTable(block, client) {
275
267
  }
276
268
  return [textBlock(lines.join('\n'))];
277
269
  }
278
- async function convertToggle(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
270
+ async function convertToggle(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases) {
279
271
  const t = block;
280
272
  const header = richTextToMarkdown(t.toggle.rich_text);
281
273
  const results = [textBlock(`**${header}**`)];
282
274
  if (block.has_children) {
283
275
  const children = await client.getBlockChildren(block.id);
284
- const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap, unsupportedBlocks);
276
+ const childBlocks = await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, '> ', databaseIdMap, unsupportedBlocks, unresolvedChildDatabases);
285
277
  results.push(...childBlocks);
286
278
  }
287
279
  return results;
288
280
  }
289
- function convertChildDatabase(block, databaseIdMap) {
281
+ function convertChildDatabase(block, databaseIdMap, unresolvedChildDatabases) {
290
282
  const cd = block;
291
283
  const title = cd.child_database?.title || 'Untitled Database';
292
284
  const notionDbId = normalizeId(block.id);
@@ -295,7 +287,10 @@ function convertChildDatabase(block, databaseIdMap) {
295
287
  if (kbDatabaseId) {
296
288
  return [{ blockType: 'database_embed', databaseId: kbDatabaseId }];
297
289
  }
298
- // No mapping available — emit text placeholder
290
+ // No mapping available — collect for later creation and emit text placeholder
291
+ if (unresolvedChildDatabases) {
292
+ unresolvedChildDatabases.set(notionDbId, title);
293
+ }
299
294
  console.warn(` No KB mapping for Notion database "${title}" (${notionDbId})`);
300
295
  return [textBlock(`*(Embedded database: ${title})*`)];
301
296
  }
@@ -406,7 +401,7 @@ function convertLinkToPage(block, pagePathMap) {
406
401
  // Target not in import set
407
402
  return [textBlock(`*(Link to Notion page: ${targetId})*`)];
408
403
  }
409
- async function convertSyncedBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
404
+ async function convertSyncedBlock(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases) {
410
405
  const sb = block;
411
406
  // Get the source block ID (either this block or the original)
412
407
  const sourceId = sb.synced_block.synced_from?.block_id ?? block.id;
@@ -417,19 +412,19 @@ async function convertSyncedBlock(block, client, pagePathMap, visitedSyncedBlock
417
412
  visitedSyncedBlocks.add(sourceId);
418
413
  try {
419
414
  const children = await client.getBlockChildren(sourceId);
420
- return await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, undefined, databaseIdMap, unsupportedBlocks);
415
+ return await convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, undefined, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases);
421
416
  }
422
417
  finally {
423
418
  visitedSyncedBlocks.delete(sourceId);
424
419
  }
425
420
  }
426
- async function convertColumnList(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks) {
421
+ async function convertColumnList(block, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases) {
427
422
  const children = await client.getBlockChildren(block.id);
428
423
  const results = [];
429
424
  for (const column of children) {
430
425
  if (column.type === 'column' && column.has_children) {
431
426
  const columnChildren = await client.getBlockChildren(column.id);
432
- const converted = await convertAndMergeChildren(columnChildren, client, pagePathMap, visitedSyncedBlocks, undefined, databaseIdMap, unsupportedBlocks);
427
+ const converted = await convertAndMergeChildren(columnChildren, client, pagePathMap, visitedSyncedBlocks, undefined, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases);
433
428
  results.push(...converted);
434
429
  }
435
430
  }
@@ -578,10 +573,10 @@ function mergeConsecutiveListBlocks(blocks) {
578
573
  /**
579
574
  * Convert children blocks, merge consecutive list items, and optionally indent.
580
575
  */
581
- async function convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, indent, databaseIdMap, unsupportedBlocks) {
576
+ async function convertAndMergeChildren(children, client, pagePathMap, visitedSyncedBlocks, indent, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases) {
582
577
  const blocks = [];
583
578
  for (const child of children) {
584
- const converted = await convertBlock(child, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks);
579
+ const converted = await convertBlock(child, client, pagePathMap, visitedSyncedBlocks, databaseIdMap, unsupportedBlocks, unresolvedChildDatabases);
585
580
  blocks.push(...converted);
586
581
  }
587
582
  const merged = mergeConsecutiveListBlocks(blocks);
@@ -30,8 +30,8 @@ export interface MappedColumn {
30
30
  export interface ScalarColumn {
31
31
  notionPropertyId: string;
32
32
  notionPropertyName: string;
33
- notionType: 'number' | 'date' | 'url' | 'email' | 'rich_text' | 'checkbox';
34
- moxnType: 'number' | 'date' | 'url' | 'email' | 'text';
33
+ notionType: 'number' | 'date' | 'url' | 'email' | 'rich_text' | 'checkbox' | 'relation';
34
+ moxnType: 'number' | 'date' | 'url' | 'email' | 'text' | 'page_ref';
35
35
  }
36
36
  /** Parsed Notion column that will be rendered as text in a Properties section. */
37
37
  export interface UnmappedColumn {
@@ -50,7 +50,7 @@ export interface ParsedDatabaseSchema {
50
50
  /** Scalar value for a single database entry property. */
51
51
  export interface ScalarValue {
52
52
  columnName: string;
53
- moxnType: 'number' | 'date' | 'url' | 'email' | 'text';
53
+ moxnType: 'number' | 'date' | 'url' | 'email' | 'text' | 'page_ref';
54
54
  textValue?: string | null;
55
55
  numberValue?: number | null;
56
56
  dateValue?: string | null;
@@ -123,6 +123,14 @@ export function parseDatabaseSchema(db) {
123
123
  moxnType: 'text',
124
124
  });
125
125
  }
126
+ else if (prop.type === 'relation') {
127
+ scalarColumns.push({
128
+ notionPropertyId: prop.id,
129
+ notionPropertyName: propName,
130
+ notionType: 'relation',
131
+ moxnType: 'page_ref',
132
+ });
133
+ }
126
134
  else {
127
135
  // Warn on property types that have no meaningful static value
128
136
  if (prop.type === 'button' || prop.type === 'ai_text') {
@@ -175,9 +183,24 @@ export function parseEntryValues(page, schema) {
175
183
  // Check if this is a scalar column
176
184
  const scalar = scalarByName.get(propName);
177
185
  if (scalar) {
178
- const extracted = extractScalarValue(propValue, scalar);
179
- if (extracted) {
180
- scalarValues.set(propName, extracted);
186
+ // page_ref columns extract relation page IDs (handled differently from standard scalars)
187
+ if (scalar.moxnType === 'page_ref') {
188
+ const refs = (propValue.relation ?? [])
189
+ .map((r) => normalizeId(r.id))
190
+ .filter(Boolean);
191
+ if (refs.length > 0) {
192
+ scalarValues.set(propName, {
193
+ columnName: propName,
194
+ moxnType: 'page_ref',
195
+ textValue: JSON.stringify(refs.map((id) => `notion:${id}`)),
196
+ });
197
+ }
198
+ }
199
+ else {
200
+ const extracted = extractScalarValue(propValue, scalar);
201
+ if (extracted) {
202
+ scalarValues.set(propName, extracted);
203
+ }
181
204
  }
182
205
  continue;
183
206
  }
@@ -10,6 +10,7 @@
10
10
  import type { SectionInput } from '../types.js';
11
11
  export interface NotionIdMapping {
12
12
  notionIdToKbPath: Map<string, string>;
13
+ notionIdToTitle?: Map<string, string>;
13
14
  }
14
15
  export interface ExtractedReference {
15
16
  sectionIndex: number;
@@ -125,17 +125,19 @@ export function resolveNotionReferences(sections, mapping) {
125
125
  text = text.replace(RELATION_REF_RE, (_match, rawId) => {
126
126
  const nid = normalizeId(rawId);
127
127
  const kbPath = mapping.notionIdToKbPath.get(nid);
128
+ const title = mapping.notionIdToTitle?.get(nid);
128
129
  if (kbPath) {
130
+ const displayText = title || kbPath;
129
131
  references.push({
130
132
  sectionIndex,
131
133
  targetNotionId: nid,
132
134
  targetKbPath: kbPath,
133
- displayText: kbPath,
135
+ displayText,
134
136
  });
135
- return `[${kbPath}](${kbPath})`;
137
+ return `[${displayText}](${kbPath})`;
136
138
  }
137
- // Unresolved — keep the ID
138
- return nid;
139
+ // Unresolved — show title if available, otherwise keep the ID
140
+ return title || nid;
139
141
  });
140
142
  return { blockType: 'text', text };
141
143
  });
@@ -151,9 +153,10 @@ export function resolveRelationIds(value, mapping) {
151
153
  return value.replace(RELATION_REF_RE, (_match, rawId) => {
152
154
  const nid = normalizeId(rawId);
153
155
  const kbPath = mapping.notionIdToKbPath.get(nid);
156
+ const title = mapping.notionIdToTitle?.get(nid);
154
157
  if (kbPath) {
155
- return `[${kbPath}](${kbPath})`;
158
+ return `[${title || kbPath}](${kbPath})`;
156
159
  }
157
- return nid;
160
+ return title || nid;
158
161
  });
159
162
  }
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import type { ExtractedDocument } from '../types.js';
11
11
  import { MigrationSource, type SourceConfig } from './base.js';
12
+ import { NotionApiClient } from './notion-api.js';
12
13
  import type { NotionPage, NotionDatabase } from './notion-api.js';
13
14
  import { type PagePathMap } from './notion-blocks.js';
14
15
  import { type ParsedDatabaseSchema } from './notion-databases.js';
@@ -48,6 +49,7 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
48
49
  private pageTree;
49
50
  private allPages;
50
51
  private pagePathMap;
52
+ private pageTitleMap;
51
53
  private databases;
52
54
  private databaseEntryPageIds;
53
55
  private _documentCount;
@@ -59,6 +61,8 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
59
61
  private _unsupportedBlocks;
60
62
  /** Map of Notion database ID → KB database ID, set before extraction. */
61
63
  private _databaseIdMap;
64
+ /** Collects inline databases encountered during extraction but not in databaseIdMap. */
65
+ private _unresolvedChildDatabases;
62
66
  constructor(config: NotionSourceConfig);
63
67
  /**
64
68
  * Set the database ID map (Notion DB ID → KB DB ID).
@@ -76,6 +80,18 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
76
80
  */
77
81
  getDatabaseImports(): NotionDatabaseImportInfo[];
78
82
  extract(): AsyncGenerator<ExtractedDocument, void, unknown>;
83
+ /** Get extraction stats for reconciling discovered vs extracted counts. */
84
+ getExtractionStats(): {
85
+ discovered: number;
86
+ extracted: number;
87
+ skippedEmpty: string[];
88
+ skippedNoContent: string[];
89
+ errors: string[];
90
+ };
91
+ /** Get unresolved inline databases found during extraction. */
92
+ getUnresolvedInlineDatabases(): Map<string, string>;
93
+ /** Expose the Notion API client (for fetching inline DB schemas). */
94
+ getNotionApiClient(): NotionApiClient;
79
95
  /** Clean up temp files after migration completes. */
80
96
  cleanup(): Promise<void>;
81
97
  private buildPageTreeInternal;
@@ -96,6 +112,7 @@ export interface PageTreeResult {
96
112
  tree: PageTreeNode[];
97
113
  allPages: PageTreeNode[];
98
114
  pagePathMap: PagePathMap;
115
+ pageTitleMap: Map<string, string>;
99
116
  databaseEntryPageIds: Set<string>;
100
117
  }
101
118
  /**
@@ -24,6 +24,7 @@ export class NotionSource extends MigrationSource {
24
24
  pageTree = [];
25
25
  allPages = []; // flat list, depth-first order
26
26
  pagePathMap = new Map();
27
+ pageTitleMap = new Map();
27
28
  databases = [];
28
29
  databaseEntryPageIds = new Set();
29
30
  _documentCount = 0;
@@ -36,6 +37,8 @@ export class NotionSource extends MigrationSource {
36
37
  _unsupportedBlocks = new Map();
37
38
  /** Map of Notion database ID → KB database ID, set before extraction. */
38
39
  _databaseIdMap = new Map();
40
+ /** Collects inline databases encountered during extraction but not in databaseIdMap. */
41
+ _unresolvedChildDatabases = new Map();
39
42
  constructor(config) {
40
43
  super(config);
41
44
  this.client = new NotionApiClient(config.token);
@@ -156,6 +159,7 @@ export class NotionSource extends MigrationSource {
156
159
  this._skippedNoContent = [];
157
160
  this._extractionErrors = [];
158
161
  this._unsupportedBlocks = new Map();
162
+ this._unresolvedChildDatabases = new Map();
159
163
  let yielded = 0;
160
164
  // Walk pages depth-first
161
165
  for (const node of this.allPages) {
@@ -224,6 +228,27 @@ export class NotionSource extends MigrationSource {
224
228
  console.log(` ⚠ Count mismatch: expected ${expectedYield} (${this._documentCount} - ${totalSkipped} skipped) but extracted ${yielded}`);
225
229
  }
226
230
  }
231
+ /** Get extraction stats for reconciling discovered vs extracted counts. */
232
+ getExtractionStats() {
233
+ const totalSkipped = this._skippedEmpty.length +
234
+ this._skippedNoContent.length +
235
+ this._extractionErrors.length;
236
+ return {
237
+ discovered: this._documentCount,
238
+ extracted: this._documentCount - totalSkipped,
239
+ skippedEmpty: [...this._skippedEmpty],
240
+ skippedNoContent: [...this._skippedNoContent],
241
+ errors: [...this._extractionErrors],
242
+ };
243
+ }
244
+ /** Get unresolved inline databases found during extraction. */
245
+ getUnresolvedInlineDatabases() {
246
+ return new Map(this._unresolvedChildDatabases);
247
+ }
248
+ /** Expose the Notion API client (for fetching inline DB schemas). */
249
+ getNotionApiClient() {
250
+ return this.client;
251
+ }
227
252
  /** Clean up temp files after migration completes. */
228
253
  async cleanup() {
229
254
  await this.mediaDownloader.cleanup();
@@ -240,6 +265,7 @@ export class NotionSource extends MigrationSource {
240
265
  this.pageTree = result.tree;
241
266
  this.allPages = result.allPages;
242
267
  this.pagePathMap = result.pagePathMap;
268
+ this.pageTitleMap = result.pageTitleMap;
243
269
  this.databaseEntryPageIds = result.databaseEntryPageIds;
244
270
  }
245
271
  // ============================================
@@ -287,11 +313,12 @@ export class NotionSource extends MigrationSource {
287
313
  let sections = await blocksToSections(blocks, this.client, this.pagePathMap, {
288
314
  databaseIdMap: this._databaseIdMap.size > 0 ? this._databaseIdMap : undefined,
289
315
  unsupportedBlocks: this._unsupportedBlocks,
316
+ unresolvedChildDatabases: this._unresolvedChildDatabases,
290
317
  });
291
318
  // Download Notion-hosted media files
292
319
  sections = await this.downloadSectionMedia(sections);
293
320
  // Resolve cross-references
294
- const { sections: resolvedSections, references } = resolveNotionReferences(sections, { notionIdToKbPath: this.pagePathMap });
321
+ const { sections: resolvedSections, references } = resolveNotionReferences(sections, { notionIdToKbPath: this.pagePathMap, notionIdToTitle: this.pageTitleMap });
295
322
  sections = resolvedSections;
296
323
  if (sections.length === 0) {
297
324
  this._skippedNoContent.push(node.title);
@@ -333,13 +360,14 @@ export class NotionSource extends MigrationSource {
333
360
  const contentSections = await blocksToSections(blocks, this.client, this.pagePathMap, {
334
361
  databaseIdMap: this._databaseIdMap.size > 0 ? this._databaseIdMap : undefined,
335
362
  unsupportedBlocks: this._unsupportedBlocks,
363
+ unresolvedChildDatabases: this._unresolvedChildDatabases,
336
364
  });
337
365
  sections.push(...contentSections);
338
366
  }
339
367
  // Download media
340
368
  let processedSections = await this.downloadSectionMedia(sections);
341
369
  // Resolve cross-references
342
- const { sections: resolvedSections, references } = resolveNotionReferences(processedSections, { notionIdToKbPath: this.pagePathMap });
370
+ const { sections: resolvedSections, references } = resolveNotionReferences(processedSections, { notionIdToKbPath: this.pagePathMap, notionIdToTitle: this.pageTitleMap });
343
371
  processedSections = resolvedSections;
344
372
  const nid = normalizeId(entry.id);
345
373
  const kbPath = this.pagePathMap.get(nid) ?? slug;
@@ -392,6 +420,7 @@ export class NotionSource extends MigrationSource {
392
420
  */
393
421
  export function buildPageTree(pages, options) {
394
422
  const pagePathMap = new Map();
423
+ const pageTitleMap = new Map();
395
424
  const databaseEntryPageIds = new Set();
396
425
  const tree = [];
397
426
  const allPages = [];
@@ -493,7 +522,11 @@ export function buildPageTree(pages, options) {
493
522
  }
494
523
  // Build tree recursively
495
524
  const buildNode = (page, parentPath, depth, siblingSlugCounts) => {
496
- if (options?.maxDepth !== undefined && depth > options.maxDepth) {
525
+ const nid = normalizeId(page.id);
526
+ const isDatabaseSubtree = syntheticDatabaseIds.has(nid) || databaseEntryPageIds.has(nid);
527
+ if (options?.maxDepth !== undefined &&
528
+ depth > options.maxDepth &&
529
+ !isDatabaseSubtree) {
497
530
  return null;
498
531
  }
499
532
  const title = getPageTitle(page);
@@ -505,7 +538,6 @@ export function buildPageTree(pages, options) {
505
538
  slug = `${slug}-${existing + 1}`;
506
539
  }
507
540
  const kbPath = parentPath ? `${parentPath}/${slug}` : slug;
508
- const nid = normalizeId(page.id);
509
541
  const isDatabaseEntry = databaseEntryPageIds.has(nid);
510
542
  const node = {
511
543
  page,
@@ -517,8 +549,9 @@ export function buildPageTree(pages, options) {
517
549
  parentDatabaseId: page.parent.type === 'database_id' ? page.parent.database_id : undefined,
518
550
  children: [],
519
551
  };
520
- // Register in path map
552
+ // Register in path map and title map
521
553
  pagePathMap.set(nid, kbPath);
554
+ pageTitleMap.set(nid, title);
522
555
  // Process children
523
556
  const childPages = childrenByParent.get(nid) ?? [];
524
557
  const childSlugCounts = new Map();
@@ -546,7 +579,7 @@ export function buildPageTree(pages, options) {
546
579
  }
547
580
  };
548
581
  flatten(tree);
549
- return { tree, allPages, pagePathMap, databaseEntryPageIds };
582
+ return { tree, allPages, pagePathMap, pageTitleMap, databaseEntryPageIds };
550
583
  }
551
584
  /** Extract the parent page ID from a Notion page's parent field. */
552
585
  function getParentPageId(page) {
package/dist/types.d.ts CHANGED
@@ -172,6 +172,10 @@ export interface MigrationLog {
172
172
  skipped: number;
173
173
  failed: number;
174
174
  duration: number;
175
+ /** Upper-bound page count from discovery (before extraction skips). */
176
+ discovered?: number;
177
+ /** Pages skipped during extraction (empty, no content, errors). */
178
+ skippedDuringExtraction?: number;
175
179
  };
176
180
  }
177
181
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moxn/kb-migrate",
3
- "version": "0.4.24",
3
+ "version": "0.4.26",
4
4
  "description": "Migration tool for importing documents into Moxn Knowledge Base from local files, Notion, Google Docs, and more",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",