@moxn/kb-migrate 0.4.25 → 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
@@ -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,9 +467,20 @@ 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) {
@@ -445,10 +489,77 @@ program
445
489
  }
446
490
  // Make the map available during extraction
447
491
  source.setDatabaseIdMap(databaseIdMap);
448
- console.log(` ${databaseIdMap.size} database(s) pre-created for embed resolution.\n`);
492
+ console.log(` ${databaseIdMap.size} database(s) ready for embed resolution.\n`);
449
493
  }
450
494
  // Step 3: Run page migration (validate is idempotent, extract uses databaseIdMap)
451
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
+ }
452
563
  // Step 4: Resolve cross-references (between page migration and database finalization)
453
564
  if (!opts.dryRun) {
454
565
  const refsWithData = log.results.filter((r) => r.references?.length && r.sectionIds?.length);
@@ -583,15 +694,26 @@ program
583
694
  * Called before document extraction so child_database blocks can resolve.
584
695
  * Returns the KB database ID.
585
696
  */
586
- async function preCreateNotionDatabase(client, dbImport) {
697
+ async function preCreateNotionDatabase(client, dbImport, existingMappings) {
587
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
+ }
588
710
  console.log(` Pre-creating database: ${schema.name}`);
589
711
  const db = await client.createDatabase({
590
712
  name: schema.name,
591
713
  description: schema.description || undefined,
592
714
  });
593
715
  console.log(` Database created: ${db.id}`);
594
- // Store Notion → KB database mapping
716
+ // Store Notion → KB database mapping (upsert handles duplicates)
595
717
  try {
596
718
  await client.createNotionDatabaseMapping({
597
719
  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
  }
@@ -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.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",