@moxn/kb-migrate 0.4.37 → 0.4.38
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.js +16 -0
- package/dist/migrate-document.test.d.ts +1 -0
- package/dist/migrate-document.test.js +70 -0
- package/dist/sources/notion.d.ts +18 -0
- package/dist/sources/notion.js +37 -10
- package/dist/sources/notion.test.js +26 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -93,6 +93,22 @@ export class MoxnClient {
|
|
|
93
93
|
};
|
|
94
94
|
}
|
|
95
95
|
catch (updateError) {
|
|
96
|
+
// Re-importing BYTE-IDENTICAL content makes the server reject the
|
|
97
|
+
// empty commit ("No changes to commit"). For an importer that's a
|
|
98
|
+
// NO-OP, not a failure — report a successful (no-op) update, matching
|
|
99
|
+
// the grammar path's idempotent re-import. (Audit finding B.)
|
|
100
|
+
const msg = updateError instanceof Error ? updateError.message : '';
|
|
101
|
+
if (msg.includes('No changes to commit')) {
|
|
102
|
+
return {
|
|
103
|
+
sourcePath: doc.sourcePath,
|
|
104
|
+
documentPath,
|
|
105
|
+
status: 'updated',
|
|
106
|
+
documentId: error.documentId,
|
|
107
|
+
branchId: error.branchId,
|
|
108
|
+
sourcePageId: doc.metadata?.notionPageId,
|
|
109
|
+
duration: Date.now() - startTime,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
96
112
|
return {
|
|
97
113
|
sourcePath: doc.sourcePath,
|
|
98
114
|
documentPath,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
2
|
+
import { MoxnClient } from './client.js';
|
|
3
|
+
/** Minimal fetch Response stand-in (client reads .ok/.status/.json()). */
|
|
4
|
+
function mockResponse(status, body) {
|
|
5
|
+
return {
|
|
6
|
+
ok: status >= 200 && status < 300,
|
|
7
|
+
status,
|
|
8
|
+
json: async () => body,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
const doc = {
|
|
12
|
+
relativePath: 'runbook',
|
|
13
|
+
name: 'Runbook',
|
|
14
|
+
sections: [{ name: 'Steps', content: [{ blockType: 'text', text: 'body' }] }],
|
|
15
|
+
sourcePath: 'notion://x',
|
|
16
|
+
metadata: { notionPageId: 'p1' },
|
|
17
|
+
};
|
|
18
|
+
function client() {
|
|
19
|
+
return new MoxnClient({
|
|
20
|
+
apiUrl: 'http://test',
|
|
21
|
+
apiKey: 'k',
|
|
22
|
+
basePath: '/',
|
|
23
|
+
onConflict: 'update',
|
|
24
|
+
dryRun: false,
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
afterEach(() => vi.restoreAllMocks());
|
|
28
|
+
describe('migrateDocument — finding B: unchanged re-import is a no-op, not a failure', () => {
|
|
29
|
+
it("treats the server's 'No changes to commit' on update as a no-op success", async () => {
|
|
30
|
+
// create → 409 (path exists, id recovered) → update → 400 No-changes.
|
|
31
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation((async (url, init) => {
|
|
32
|
+
const u = String(url);
|
|
33
|
+
const method = (init?.method ?? 'GET').toUpperCase();
|
|
34
|
+
if (method === 'POST' && u.endsWith('/api/v1/kb/documents')) {
|
|
35
|
+
return mockResponse(409, {
|
|
36
|
+
error: 'exists',
|
|
37
|
+
documentId: 'doc-1',
|
|
38
|
+
branchId: 'br-1',
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
if (method === 'PUT' && u.includes('/api/v1/kb/documents/')) {
|
|
42
|
+
return mockResponse(400, {
|
|
43
|
+
error: 'No changes to commit — working hash equals head body hash',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return mockResponse(200, {});
|
|
47
|
+
}));
|
|
48
|
+
const res = await client().migrateDocument(doc, '/', 'update', false);
|
|
49
|
+
expect(res.status).toBe('updated'); // NOT 'failed'
|
|
50
|
+
expect(res.documentId).toBe('doc-1');
|
|
51
|
+
});
|
|
52
|
+
it('still reports a genuine update error as failed', async () => {
|
|
53
|
+
vi.spyOn(globalThis, 'fetch').mockImplementation((async (url, init) => {
|
|
54
|
+
const u = String(url);
|
|
55
|
+
const method = (init?.method ?? 'GET').toUpperCase();
|
|
56
|
+
if (method === 'POST' && u.endsWith('/api/v1/kb/documents')) {
|
|
57
|
+
return mockResponse(409, {
|
|
58
|
+
error: 'exists',
|
|
59
|
+
documentId: 'doc-1',
|
|
60
|
+
branchId: 'br-1',
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
if (method === 'PUT')
|
|
64
|
+
return mockResponse(500, { error: 'boom' });
|
|
65
|
+
return mockResponse(200, {});
|
|
66
|
+
}));
|
|
67
|
+
const res = await client().migrateDocument(doc, '/', 'update', false);
|
|
68
|
+
expect(res.status).toBe('failed');
|
|
69
|
+
});
|
|
70
|
+
});
|
package/dist/sources/notion.d.ts
CHANGED
|
@@ -23,6 +23,8 @@ export interface NotionSourceConfig extends SourceConfig {
|
|
|
23
23
|
export interface PageTreeNode {
|
|
24
24
|
page: NotionPage;
|
|
25
25
|
title: string;
|
|
26
|
+
/** Disambiguated document name — `slugify(name) === slug` (finding D). May differ from `title` for same-titled siblings. */
|
|
27
|
+
name: string;
|
|
26
28
|
slug: string;
|
|
27
29
|
children: PageTreeNode[];
|
|
28
30
|
kbPath: string;
|
|
@@ -50,6 +52,7 @@ export declare class NotionSource extends MigrationSource<NotionSourceConfig> {
|
|
|
50
52
|
private allPages;
|
|
51
53
|
private pagePathMap;
|
|
52
54
|
private pageTitleMap;
|
|
55
|
+
private pageNameMap;
|
|
53
56
|
private databases;
|
|
54
57
|
private databaseEntryPageIds;
|
|
55
58
|
private _documentCount;
|
|
@@ -113,6 +116,8 @@ export interface PageTreeResult {
|
|
|
113
116
|
allPages: PageTreeNode[];
|
|
114
117
|
pagePathMap: PagePathMap;
|
|
115
118
|
pageTitleMap: Map<string, string>;
|
|
119
|
+
/** nid → disambiguated document name (parallel to pagePathMap; finding D). */
|
|
120
|
+
pageNameMap: Map<string, string>;
|
|
116
121
|
databaseEntryPageIds: Set<string>;
|
|
117
122
|
}
|
|
118
123
|
/**
|
|
@@ -136,3 +141,16 @@ export declare function buildPageTree(pages: NotionPage[], options?: BuildPageTr
|
|
|
136
141
|
* Falls back to "untitled" when the result is empty (e.g. emoji-only or non-Latin titles).
|
|
137
142
|
*/
|
|
138
143
|
export declare function slugify(title: string): string;
|
|
144
|
+
/**
|
|
145
|
+
* Disambiguate a sibling page whose slug collides with an earlier sibling,
|
|
146
|
+
* suffixing BOTH the name and the slug so the bijective invariant
|
|
147
|
+
* `slugify(name) === slug` holds — the server's `createDocumentWithSections`
|
|
148
|
+
* hard-asserts it (the #177 name/path cutover). The legacy tree builder
|
|
149
|
+
* suffixed only the PATH (`notes` → `notes-2`) while the name stayed `Notes`,
|
|
150
|
+
* so a second same-titled sibling was rejected by that assert (audit finding D).
|
|
151
|
+
* Mutates `siblingSlugCounts` (keyed on the BASE slug) the way the builder expects.
|
|
152
|
+
*/
|
|
153
|
+
export declare function disambiguateSibling(title: string, siblingSlugCounts: Map<string, number>): {
|
|
154
|
+
name: string;
|
|
155
|
+
slug: string;
|
|
156
|
+
};
|
package/dist/sources/notion.js
CHANGED
|
@@ -25,6 +25,7 @@ export class NotionSource extends MigrationSource {
|
|
|
25
25
|
allPages = []; // flat list, depth-first order
|
|
26
26
|
pagePathMap = new Map();
|
|
27
27
|
pageTitleMap = new Map();
|
|
28
|
+
pageNameMap = new Map();
|
|
28
29
|
databases = [];
|
|
29
30
|
databaseEntryPageIds = new Set();
|
|
30
31
|
_documentCount = 0;
|
|
@@ -266,6 +267,7 @@ export class NotionSource extends MigrationSource {
|
|
|
266
267
|
this.allPages = result.allPages;
|
|
267
268
|
this.pagePathMap = result.pagePathMap;
|
|
268
269
|
this.pageTitleMap = result.pageTitleMap;
|
|
270
|
+
this.pageNameMap = result.pageNameMap;
|
|
269
271
|
this.databaseEntryPageIds = result.databaseEntryPageIds;
|
|
270
272
|
}
|
|
271
273
|
// ============================================
|
|
@@ -326,7 +328,7 @@ export class NotionSource extends MigrationSource {
|
|
|
326
328
|
}
|
|
327
329
|
return {
|
|
328
330
|
relativePath: node.kbPath,
|
|
329
|
-
name: node.
|
|
331
|
+
name: node.name,
|
|
330
332
|
sections,
|
|
331
333
|
sourcePath: `notion://${node.page.id}`,
|
|
332
334
|
references: references.length > 0 ? references : undefined,
|
|
@@ -373,7 +375,7 @@ export class NotionSource extends MigrationSource {
|
|
|
373
375
|
const kbPath = this.pagePathMap.get(nid) ?? slug;
|
|
374
376
|
return {
|
|
375
377
|
relativePath: kbPath,
|
|
376
|
-
name: title,
|
|
378
|
+
name: this.pageNameMap.get(nid) ?? title,
|
|
377
379
|
sections: processedSections.length > 0
|
|
378
380
|
? processedSections
|
|
379
381
|
: [
|
|
@@ -421,6 +423,7 @@ export class NotionSource extends MigrationSource {
|
|
|
421
423
|
export function buildPageTree(pages, options) {
|
|
422
424
|
const pagePathMap = new Map();
|
|
423
425
|
const pageTitleMap = new Map();
|
|
426
|
+
const pageNameMap = new Map();
|
|
424
427
|
const databaseEntryPageIds = new Set();
|
|
425
428
|
const tree = [];
|
|
426
429
|
const allPages = [];
|
|
@@ -530,18 +533,16 @@ export function buildPageTree(pages, options) {
|
|
|
530
533
|
return null;
|
|
531
534
|
}
|
|
532
535
|
const title = getPageTitle(page);
|
|
533
|
-
|
|
534
|
-
//
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
if (existing > 0) {
|
|
538
|
-
slug = `${slug}-${existing + 1}`;
|
|
539
|
-
}
|
|
536
|
+
// Dedup sibling slugs — suffix BOTH name and slug so slugify(name) === slug
|
|
537
|
+
// (the server's hard bijection assert; audit finding D). Legacy code
|
|
538
|
+
// suffixed only the path, so a same-titled sibling was rejected.
|
|
539
|
+
const { name, slug } = disambiguateSibling(title, siblingSlugCounts);
|
|
540
540
|
const kbPath = parentPath ? `${parentPath}/${slug}` : slug;
|
|
541
541
|
const isDatabaseEntry = databaseEntryPageIds.has(nid);
|
|
542
542
|
const node = {
|
|
543
543
|
page,
|
|
544
544
|
title,
|
|
545
|
+
name,
|
|
545
546
|
slug,
|
|
546
547
|
kbPath,
|
|
547
548
|
isDatabaseEntry,
|
|
@@ -552,6 +553,7 @@ export function buildPageTree(pages, options) {
|
|
|
552
553
|
// Register in path map and title map
|
|
553
554
|
pagePathMap.set(nid, kbPath);
|
|
554
555
|
pageTitleMap.set(nid, title);
|
|
556
|
+
pageNameMap.set(nid, name);
|
|
555
557
|
// Process children
|
|
556
558
|
const childPages = childrenByParent.get(nid) ?? [];
|
|
557
559
|
const childSlugCounts = new Map();
|
|
@@ -579,7 +581,14 @@ export function buildPageTree(pages, options) {
|
|
|
579
581
|
}
|
|
580
582
|
};
|
|
581
583
|
flatten(tree);
|
|
582
|
-
return {
|
|
584
|
+
return {
|
|
585
|
+
tree,
|
|
586
|
+
allPages,
|
|
587
|
+
pagePathMap,
|
|
588
|
+
pageTitleMap,
|
|
589
|
+
pageNameMap,
|
|
590
|
+
databaseEntryPageIds,
|
|
591
|
+
};
|
|
583
592
|
}
|
|
584
593
|
/** Extract the parent page ID from a Notion page's parent field. */
|
|
585
594
|
function getParentPageId(page) {
|
|
@@ -614,3 +623,21 @@ export function slugify(title) {
|
|
|
614
623
|
.replace(/^-+|-+$/g, '');
|
|
615
624
|
return cleaned.length > 0 ? cleaned : 'untitled';
|
|
616
625
|
}
|
|
626
|
+
/**
|
|
627
|
+
* Disambiguate a sibling page whose slug collides with an earlier sibling,
|
|
628
|
+
* suffixing BOTH the name and the slug so the bijective invariant
|
|
629
|
+
* `slugify(name) === slug` holds — the server's `createDocumentWithSections`
|
|
630
|
+
* hard-asserts it (the #177 name/path cutover). The legacy tree builder
|
|
631
|
+
* suffixed only the PATH (`notes` → `notes-2`) while the name stayed `Notes`,
|
|
632
|
+
* so a second same-titled sibling was rejected by that assert (audit finding D).
|
|
633
|
+
* Mutates `siblingSlugCounts` (keyed on the BASE slug) the way the builder expects.
|
|
634
|
+
*/
|
|
635
|
+
export function disambiguateSibling(title, siblingSlugCounts) {
|
|
636
|
+
const baseSlug = slugify(title);
|
|
637
|
+
const existing = siblingSlugCounts.get(baseSlug) ?? 0;
|
|
638
|
+
siblingSlugCounts.set(baseSlug, existing + 1);
|
|
639
|
+
if (existing === 0)
|
|
640
|
+
return { name: title, slug: baseSlug };
|
|
641
|
+
const name = `${title} ${existing + 1}`;
|
|
642
|
+
return { name, slug: slugify(name) };
|
|
643
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
-
import { slugify } from './notion.js';
|
|
2
|
+
import { slugify, disambiguateSibling } from './notion.js';
|
|
3
3
|
describe('slugify (notion)', () => {
|
|
4
4
|
it('lowercases', () => {
|
|
5
5
|
expect(slugify('MyDoc')).toBe('mydoc');
|
|
@@ -43,3 +43,28 @@ describe('slugify (notion)', () => {
|
|
|
43
43
|
expect(slugify('🎉')).toBe('untitled');
|
|
44
44
|
});
|
|
45
45
|
});
|
|
46
|
+
describe('disambiguateSibling (D — dedup name AND path together)', () => {
|
|
47
|
+
it('returns the title unchanged for the first occurrence', () => {
|
|
48
|
+
const counts = new Map();
|
|
49
|
+
expect(disambiguateSibling('Notes', counts)).toEqual({
|
|
50
|
+
name: 'Notes',
|
|
51
|
+
slug: 'notes',
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
it('suffixes BOTH name and slug for siblings, keeping the bijection', () => {
|
|
55
|
+
const counts = new Map();
|
|
56
|
+
disambiguateSibling('Notes', counts); // first
|
|
57
|
+
const second = disambiguateSibling('Notes', counts);
|
|
58
|
+
const third = disambiguateSibling('Notes', counts);
|
|
59
|
+
expect(second).toEqual({ name: 'Notes 2', slug: 'notes-2' });
|
|
60
|
+
expect(third).toEqual({ name: 'Notes 3', slug: 'notes-3' });
|
|
61
|
+
// The invariant the server's assertNamePathConsistent enforces:
|
|
62
|
+
expect(slugify(second.name)).toBe(second.slug);
|
|
63
|
+
expect(slugify(third.name)).toBe(third.slug);
|
|
64
|
+
});
|
|
65
|
+
it('independent base titles do not collide in the counter', () => {
|
|
66
|
+
const counts = new Map();
|
|
67
|
+
expect(disambiguateSibling('Alpha', counts).slug).toBe('alpha');
|
|
68
|
+
expect(disambiguateSibling('Beta', counts).slug).toBe('beta');
|
|
69
|
+
});
|
|
70
|
+
});
|
package/package.json
CHANGED