@moxn/kb-migrate 0.4.25 → 0.4.27

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
@@ -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++;
@@ -70,7 +87,8 @@ async function runMigration(source, options) {
70
87
  break;
71
88
  }
72
89
  }
73
- // Build summary
90
+ // Build summary (include extraction stats if available)
91
+ const extractionStats = source.getExtractionStats?.();
74
92
  const summary = {
75
93
  total: results.length,
76
94
  created: results.filter((r) => r.status === 'created').length,
@@ -78,6 +96,12 @@ async function runMigration(source, options) {
78
96
  skipped: results.filter((r) => r.status === 'skipped').length,
79
97
  failed: results.filter((r) => r.status === 'failed').length,
80
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
+ }),
81
105
  };
82
106
  const log = {
83
107
  timestamp: new Date().toISOString(),
@@ -103,6 +127,15 @@ function printSummary(log) {
103
127
  console.log(`Base path: ${log.basePath}`);
104
128
  console.log(`Duration: ${(log.summary.duration / 1000).toFixed(1)}s`);
105
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
+ }
106
139
  console.log(`Total: ${log.summary.total}`);
107
140
  console.log(`Created: ${log.summary.created}`);
108
141
  console.log(`Updated: ${log.summary.updated}`);
@@ -434,21 +467,139 @@ program
434
467
  if (!opts.dryRun && dbImports.length > 0) {
435
468
  console.log(`\nPre-creating ${dbImports.length} database(s)...`);
436
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
+ }
437
481
  for (const dbImport of dbImports) {
438
482
  try {
439
- const kbDbId = await preCreateNotionDatabase(preClient, dbImport);
483
+ const kbDbId = await preCreateNotionDatabase(preClient, dbImport, existingMappings);
440
484
  databaseIdMap.set(dbImport.notionDatabaseId, kbDbId);
441
485
  }
442
486
  catch (error) {
443
487
  console.error(` Error pre-creating database "${dbImport.schema.name}": ${error instanceof Error ? error.message : error}`);
444
488
  }
445
489
  }
490
+ // Also add any other existing mappings (e.g. linked DB → source mappings
491
+ // from prior imports) so they're available during extraction
492
+ for (const [notionId, kbId] of existingMappings) {
493
+ if (!databaseIdMap.has(notionId)) {
494
+ databaseIdMap.set(notionId, kbId);
495
+ }
496
+ }
446
497
  // Make the map available during extraction
447
498
  source.setDatabaseIdMap(databaseIdMap);
448
- console.log(` ${databaseIdMap.size} database(s) pre-created for embed resolution.\n`);
499
+ console.log(` ${databaseIdMap.size} database(s) ready for embed resolution.\n`);
449
500
  }
450
501
  // Step 3: Run page migration (validate is idempotent, extract uses databaseIdMap)
451
502
  const log = await runMigration(source, migrationOptions);
503
+ // Step 3b: Resolve inline databases discovered during extraction
504
+ if (!opts.dryRun) {
505
+ const unresolvedDbs = source.getUnresolvedInlineDatabases();
506
+ if (unresolvedDbs.size > 0) {
507
+ console.log(`\nResolving ${unresolvedDbs.size} inline database(s) discovered during extraction...`);
508
+ const inlineClient = new MoxnClient(migrationOptions);
509
+ const notionApi = source.getNotionApiClient();
510
+ // Fetch current mappings to avoid duplicates (may have changed since pre-create)
511
+ let currentMappings = new Map();
512
+ try {
513
+ currentMappings = await inlineClient.getAllDatabaseMappings();
514
+ }
515
+ catch {
516
+ // Non-fatal — will create new databases
517
+ }
518
+ let inlineCreated = 0;
519
+ let linkedResolved = 0;
520
+ for (const [notionDbId, title] of unresolvedDbs) {
521
+ // Skip if already mapped (could happen if previously imported)
522
+ if (databaseIdMap.has(notionDbId) || currentMappings.has(notionDbId)) {
523
+ continue;
524
+ }
525
+ try {
526
+ // Fetch database schema from Notion
527
+ const notionDb = await notionApi.getDatabase(notionDbId);
528
+ const { parseDatabaseSchema } = await import('./sources/notion-databases.js');
529
+ const schema = parseDatabaseSchema(notionDb);
530
+ // Create KB database
531
+ const kbDb = await inlineClient.createDatabase({
532
+ name: schema.name || title,
533
+ description: schema.description || undefined,
534
+ });
535
+ // Store mapping
536
+ try {
537
+ await inlineClient.createNotionDatabaseMapping({
538
+ kbDatabaseId: kbDb.id,
539
+ notionDatabaseId: notionDbId,
540
+ notionDatabaseTitle: schema.name || title,
541
+ });
542
+ }
543
+ catch {
544
+ // Non-fatal — database created but mapping may fail
545
+ }
546
+ databaseIdMap.set(notionDbId, kbDb.id);
547
+ // Query entries and add to dbImports for finalization
548
+ const entries = await notionApi.queryDatabase(notionDbId);
549
+ if (entries.length > 0) {
550
+ dbImports.push({
551
+ notionDatabaseId: notionDbId,
552
+ schema,
553
+ entries: entries.map((entry) => {
554
+ const entryTitle = getPageTitle(entry);
555
+ return { page: entry, kbPath: slugify(entryTitle) };
556
+ }),
557
+ });
558
+ }
559
+ inlineCreated++;
560
+ console.log(` Created inline database: ${schema.name || title} (${kbDb.id}) — ${entries.length} entries`);
561
+ }
562
+ catch (error) {
563
+ // Check if this is a linked database — resolve to its source
564
+ const errMsg = error instanceof Error ? error.message : String(error);
565
+ if (errMsg.includes('linked database')) {
566
+ const sourceDbId = await notionApi.resolveLinkedDatabaseSource(notionDbId);
567
+ if (sourceDbId) {
568
+ // Look up the source database in our mappings
569
+ const kbDbId = databaseIdMap.get(sourceDbId) || currentMappings.get(sourceDbId);
570
+ if (kbDbId) {
571
+ databaseIdMap.set(notionDbId, kbDbId);
572
+ // Store mapping so future imports resolve during pre-create
573
+ try {
574
+ await inlineClient.createNotionDatabaseMapping({
575
+ kbDatabaseId: kbDbId,
576
+ notionDatabaseId: notionDbId,
577
+ notionDatabaseTitle: title,
578
+ });
579
+ }
580
+ catch {
581
+ // Non-fatal
582
+ }
583
+ linkedResolved++;
584
+ console.log(` Resolved linked database "${title}" (${notionDbId}) → source ${sourceDbId} → KB ${kbDbId}`);
585
+ continue;
586
+ }
587
+ }
588
+ console.warn(` Warning: Linked database "${title}" (${notionDbId}) — could not resolve source`);
589
+ }
590
+ else {
591
+ console.warn(` Warning: Failed to create inline database "${title}" (${notionDbId}): ${errMsg}`);
592
+ }
593
+ }
594
+ }
595
+ if (inlineCreated > 0) {
596
+ console.log(` Created ${inlineCreated} inline database(s). Re-run with --on-conflict update to resolve embeds.`);
597
+ }
598
+ if (linkedResolved > 0) {
599
+ console.log(` Resolved ${linkedResolved} linked database(s) to existing source databases.`);
600
+ }
601
+ }
602
+ }
452
603
  // Step 4: Resolve cross-references (between page migration and database finalization)
453
604
  if (!opts.dryRun) {
454
605
  const refsWithData = log.results.filter((r) => r.references?.length && r.sectionIds?.length);
@@ -583,15 +734,26 @@ program
583
734
  * Called before document extraction so child_database blocks can resolve.
584
735
  * Returns the KB database ID.
585
736
  */
586
- async function preCreateNotionDatabase(client, dbImport) {
737
+ async function preCreateNotionDatabase(client, dbImport, existingMappings) {
587
738
  const { schema } = dbImport;
739
+ // Check if we already have a mapping for this Notion database
740
+ const existingKbDbId = existingMappings.get(dbImport.notionDatabaseId);
741
+ if (existingKbDbId) {
742
+ // Verify the KB database still exists
743
+ const existingDb = await client.getDatabase(existingKbDbId);
744
+ if (existingDb) {
745
+ console.log(` Reusing existing database: ${existingDb.name} (${existingKbDbId})`);
746
+ return existingKbDbId;
747
+ }
748
+ console.log(` Mapped database ${existingKbDbId} no longer exists, creating new one.`);
749
+ }
588
750
  console.log(` Pre-creating database: ${schema.name}`);
589
751
  const db = await client.createDatabase({
590
752
  name: schema.name,
591
753
  description: schema.description || undefined,
592
754
  });
593
755
  console.log(` Database created: ${db.id}`);
594
- // Store Notion → KB database mapping
756
+ // Store Notion → KB database mapping (upsert handles duplicates)
595
757
  try {
596
758
  await client.createNotionDatabaseMapping({
597
759
  kbDatabaseId: db.id,
@@ -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
  }
@@ -225,6 +225,12 @@ export declare class NotionApiClient {
225
225
  searchDatabases(): Promise<NotionDatabase[]>;
226
226
  /** Get a database schema by ID. */
227
227
  getDatabase(databaseId: string): Promise<NotionDatabase>;
228
+ /**
229
+ * For a linked database, resolve the source database ID by querying
230
+ * one entry and reading its parent.database_id.
231
+ * Returns null if the database is empty or not queryable.
232
+ */
233
+ resolveLinkedDatabaseSource(linkedDbId: string): Promise<string | null>;
228
234
  /**
229
235
  * Query all entries (pages) in a database.
230
236
  */
@@ -101,6 +101,26 @@ export class NotionApiClient {
101
101
  async getDatabase(databaseId) {
102
102
  return this.request('GET', `/databases/${databaseId}`);
103
103
  }
104
+ /**
105
+ * For a linked database, resolve the source database ID by querying
106
+ * one entry and reading its parent.database_id.
107
+ * Returns null if the database is empty or not queryable.
108
+ */
109
+ async resolveLinkedDatabaseSource(linkedDbId) {
110
+ try {
111
+ const response = await this.request('POST', `/databases/${linkedDbId}/query`, { page_size: 1 });
112
+ if (response.results.length > 0) {
113
+ const entry = response.results[0];
114
+ if (entry.parent?.type === 'database_id' && entry.parent.database_id) {
115
+ return entry.parent.database_id.replace(/-/g, '');
116
+ }
117
+ }
118
+ return null;
119
+ }
120
+ catch {
121
+ return null;
122
+ }
123
+ }
104
124
  /**
105
125
  * Query all entries (pages) in a database.
106
126
  */
@@ -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);
@@ -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';
@@ -60,6 +61,8 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
60
61
  private _unsupportedBlocks;
61
62
  /** Map of Notion database ID → KB database ID, set before extraction. */
62
63
  private _databaseIdMap;
64
+ /** Collects inline databases encountered during extraction but not in databaseIdMap. */
65
+ private _unresolvedChildDatabases;
63
66
  constructor(config: NotionSourceConfig);
64
67
  /**
65
68
  * Set the database ID map (Notion DB ID → KB DB ID).
@@ -77,6 +80,18 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
77
80
  */
78
81
  getDatabaseImports(): NotionDatabaseImportInfo[];
79
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;
80
95
  /** Clean up temp files after migration completes. */
81
96
  cleanup(): Promise<void>;
82
97
  private buildPageTreeInternal;
@@ -37,6 +37,8 @@ export class NotionSource extends MigrationSource {
37
37
  _unsupportedBlocks = new Map();
38
38
  /** Map of Notion database ID → KB database ID, set before extraction. */
39
39
  _databaseIdMap = new Map();
40
+ /** Collects inline databases encountered during extraction but not in databaseIdMap. */
41
+ _unresolvedChildDatabases = new Map();
40
42
  constructor(config) {
41
43
  super(config);
42
44
  this.client = new NotionApiClient(config.token);
@@ -157,6 +159,7 @@ export class NotionSource extends MigrationSource {
157
159
  this._skippedNoContent = [];
158
160
  this._extractionErrors = [];
159
161
  this._unsupportedBlocks = new Map();
162
+ this._unresolvedChildDatabases = new Map();
160
163
  let yielded = 0;
161
164
  // Walk pages depth-first
162
165
  for (const node of this.allPages) {
@@ -225,6 +228,27 @@ export class NotionSource extends MigrationSource {
225
228
  console.log(` ⚠ Count mismatch: expected ${expectedYield} (${this._documentCount} - ${totalSkipped} skipped) but extracted ${yielded}`);
226
229
  }
227
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
+ }
228
252
  /** Clean up temp files after migration completes. */
229
253
  async cleanup() {
230
254
  await this.mediaDownloader.cleanup();
@@ -289,6 +313,7 @@ export class NotionSource extends MigrationSource {
289
313
  let sections = await blocksToSections(blocks, this.client, this.pagePathMap, {
290
314
  databaseIdMap: this._databaseIdMap.size > 0 ? this._databaseIdMap : undefined,
291
315
  unsupportedBlocks: this._unsupportedBlocks,
316
+ unresolvedChildDatabases: this._unresolvedChildDatabases,
292
317
  });
293
318
  // Download Notion-hosted media files
294
319
  sections = await this.downloadSectionMedia(sections);
@@ -335,6 +360,7 @@ export class NotionSource extends MigrationSource {
335
360
  const contentSections = await blocksToSections(blocks, this.client, this.pagePathMap, {
336
361
  databaseIdMap: this._databaseIdMap.size > 0 ? this._databaseIdMap : undefined,
337
362
  unsupportedBlocks: this._unsupportedBlocks,
363
+ unresolvedChildDatabases: this._unresolvedChildDatabases,
338
364
  });
339
365
  sections.push(...contentSections);
340
366
  }
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.25",
3
+ "version": "0.4.27",
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",