@moxn/kb-migrate 0.4.41 → 0.6.0

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 CHANGED
@@ -2,12 +2,40 @@
2
2
  * API client for Moxn KB
3
3
  */
4
4
  import * as fs from 'fs/promises';
5
- import { formatApiError } from './api-error.js';
5
+ import { formatApiError, readJson } from './api-error.js';
6
+ import { moxnFetch } from './http.js';
7
+ import { sectionsToGrammarMarkdown, buildImportFrontMatter, } from './blocks-to-grammar.js';
8
+ /**
9
+ * The warning (if any) for an import whose `--default-permission` /
10
+ * `--ai-access` did not take effect: an existing document is never
11
+ * re-permissioned by a re-import, and a server that predates the fields
12
+ * creates documents with its defaults without echoing `permissions`.
13
+ */
14
+ export function importPermissionWarning(requested, result) {
15
+ const asked = [
16
+ requested.defaultPermission &&
17
+ `--default-permission ${requested.defaultPermission}`,
18
+ requested.aiAccess && `--ai-access ${requested.aiAccess}`,
19
+ ].filter(Boolean);
20
+ if (asked.length === 0 || result.outcome === 'skipped')
21
+ return undefined;
22
+ if (result.outcome === 'updated') {
23
+ return `${asked.join(', ')} not applied: this updated an existing document, whose permissions are left as they were (change them in the app)`;
24
+ }
25
+ if (!result.permissions) {
26
+ return `${asked.join(', ')}: the server did not apply them (it predates importer permissions) — the document was created with the server defaults`;
27
+ }
28
+ return undefined;
29
+ }
6
30
  export class MoxnClient {
7
31
  apiUrl;
8
32
  apiKey;
9
33
  defaultPermission;
10
34
  aiAccess;
35
+ /** Every request goes through {@link moxnFetch} (bypass-header aware). */
36
+ fetch(url, init = {}) {
37
+ return moxnFetch(url, init);
38
+ }
11
39
  constructor(options) {
12
40
  this.apiUrl = options.apiUrl.replace(/\/$/, '');
13
41
  this.apiKey = options.apiKey;
@@ -21,6 +49,17 @@ export class MoxnClient {
21
49
  /**
22
50
  * Migrate a single document
23
51
  */
52
+ /**
53
+ * Migrate one extracted document via `import_markdown` (Phase B of the
54
+ * write-surface tightening — the REST block-input path is retired).
55
+ *
56
+ * Flow: upload/re-host every media block (local files, base64 payloads,
57
+ * remote URLs) → serialize the sections to grammar markdown → prepend a
58
+ * name/description front-matter → UPSERT via `import_markdown`
59
+ * (create-or-replace_document; `onConflict: 'skip'` skips an existing
60
+ * path). Idempotent: a byte-identical re-import is reported as a no-op
61
+ * update by the server.
62
+ */
24
63
  async migrateDocument(doc, basePath, onConflict, dryRun) {
25
64
  const startTime = Date.now();
26
65
  const documentPath = this.buildPath(basePath, doc.relativePath);
@@ -34,91 +73,37 @@ export class MoxnClient {
34
73
  };
35
74
  }
36
75
  try {
37
- // Process content blocks (convert file paths to base64)
76
+ // Upload/re-host media (local paths, base64, remote URLs) → storage keys.
38
77
  const processedSections = await this.processSections(doc.sections);
39
- // Try to create the document
40
- const createResult = await this.createDocument({
78
+ const { markdown: body, dropped } = sectionsToGrammarMarkdown(processedSections);
79
+ if (dropped > 0) {
80
+ console.error(` ! ${dropped} media block(s) had no uploadable payload and were dropped: ${doc.sourcePath}`);
81
+ }
82
+ const markdown = buildImportFrontMatter(doc.name, doc.description) + body;
83
+ const result = await this.importMarkdown({
84
+ markdown,
41
85
  path: documentPath,
42
- name: doc.name,
43
- description: doc.description,
44
- defaultPermission: this.defaultPermission,
45
- aiAccess: this.aiAccess,
46
- sections: processedSections,
86
+ onConflict,
47
87
  });
48
88
  return {
49
89
  sourcePath: doc.sourcePath,
50
- documentPath,
51
- status: 'created',
52
- documentId: createResult.id,
53
- branchId: createResult.branchId,
54
- sectionsCount: createResult.sections.length,
55
- sectionIds: createResult.sections.map((s) => s.id),
90
+ documentPath: result.path,
91
+ status: result.outcome === 'created'
92
+ ? 'created'
93
+ : result.outcome === 'updated'
94
+ ? 'updated'
95
+ : 'skipped',
96
+ documentId: result.id,
97
+ branchId: result.branchId,
98
+ sectionsCount: result.sectionIds?.length,
99
+ sectionIds: result.sectionIds,
56
100
  references: doc.references,
57
101
  sourcePageId: doc.metadata?.notionPageId,
102
+ ...this.permissionWarningFor(result, doc.sourcePath),
58
103
  duration: Date.now() - startTime,
59
104
  };
60
105
  }
61
106
  catch (error) {
62
- // Check for conflict (409)
63
- if (this.isConflictError(error)) {
64
- if (onConflict === 'skip') {
65
- return {
66
- sourcePath: doc.sourcePath,
67
- documentPath,
68
- status: 'skipped',
69
- documentId: error.documentId,
70
- branchId: error.branchId,
71
- duration: Date.now() - startTime,
72
- };
73
- }
74
- // Update existing document
75
- try {
76
- const processedSections = await this.processSections(doc.sections);
77
- const updateResult = await this.updateDocument(error.documentId, {
78
- name: doc.name,
79
- description: doc.description,
80
- sections: processedSections,
81
- });
82
- return {
83
- sourcePath: doc.sourcePath,
84
- documentPath,
85
- status: 'updated',
86
- documentId: updateResult.id,
87
- branchId: updateResult.branchId,
88
- sectionsCount: updateResult.sections.length,
89
- sectionIds: updateResult.sections.map((s) => s.id),
90
- references: doc.references,
91
- sourcePageId: doc.metadata?.notionPageId,
92
- duration: Date.now() - startTime,
93
- };
94
- }
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
- }
112
- return {
113
- sourcePath: doc.sourcePath,
114
- documentPath,
115
- status: 'failed',
116
- documentId: error.documentId,
117
- error: updateError instanceof Error ? updateError.message : 'Update failed',
118
- duration: Date.now() - startTime,
119
- };
120
- }
121
- }
122
107
  return {
123
108
  sourcePath: doc.sourcePath,
124
109
  documentPath,
@@ -128,6 +113,17 @@ export class MoxnClient {
128
113
  };
129
114
  }
130
115
  }
116
+ /**
117
+ * {@link importPermissionWarning} for this client's requested permissions,
118
+ * logged once and returned as a spreadable `{ warning }`.
119
+ */
120
+ permissionWarningFor(result, sourcePath) {
121
+ const warning = importPermissionWarning({ defaultPermission: this.defaultPermission, aiAccess: this.aiAccess }, result);
122
+ if (!warning)
123
+ return {};
124
+ console.warn(` ⚠ ${warning}: ${sourcePath}`);
125
+ return { warning };
126
+ }
131
127
  // ──────────────────────────────────────────────
132
128
  // Export methods
133
129
  // ──────────────────────────────────────────────
@@ -155,14 +151,14 @@ export class MoxnClient {
155
151
  params.set('modifiedAfter', dateFilter.modifiedAfter);
156
152
  if (dateFilter?.modifiedBefore)
157
153
  params.set('modifiedBefore', dateFilter.modifiedBefore);
158
- const response = await fetch(`${this.apiUrl}/api/v1/kb/documents?${params}`, {
154
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/documents?${params}`, {
159
155
  headers: { 'x-api-key': this.apiKey },
160
156
  });
161
157
  if (!response.ok) {
162
158
  const error = await response.text();
163
159
  throw new Error(`Failed to list documents: ${response.status} ${error}`);
164
160
  }
165
- const data = await response.json();
161
+ const data = await readJson(response);
166
162
  allDocs.push(...data.documents);
167
163
  if (!data.pagination.hasMore)
168
164
  break;
@@ -174,14 +170,14 @@ export class MoxnClient {
174
170
  * Get full document detail with sections and content.
175
171
  */
176
172
  async getDocument(documentId) {
177
- const response = await fetch(`${this.apiUrl}/api/v1/kb/documents/${documentId}`, {
173
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/documents/${documentId}`, {
178
174
  headers: { 'x-api-key': this.apiKey },
179
175
  });
180
176
  if (!response.ok) {
181
177
  const error = await response.text();
182
178
  throw new Error(`Failed to get document ${documentId}: ${response.status} ${error}`);
183
179
  }
184
- return response.json();
180
+ return readJson(response);
185
181
  }
186
182
  /**
187
183
  * Get a document's GRAMMAR markdown (front-matter + body + `:::image/:::csv/
@@ -193,7 +189,7 @@ export class MoxnClient {
193
189
  * address sections stably.
194
190
  */
195
191
  async getDocumentMarkdown(documentId, opts) {
196
- const response = await fetch(`${this.apiUrl}/api/v1/kb/export`, {
192
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/export`, {
197
193
  method: 'POST',
198
194
  headers: {
199
195
  'Content-Type': 'application/json',
@@ -209,9 +205,38 @@ export class MoxnClient {
209
205
  const body = await response.text();
210
206
  throw new Error(`Failed to get document markdown ${documentId}: ${response.status} ${body}`);
211
207
  }
212
- const data = await response.json();
208
+ const data = await readJson(response);
213
209
  return (data.result?.document ?? null);
214
210
  }
211
+ /**
212
+ * Read one document through the `read` tool (`/api/v1/kb/tools`) — the same
213
+ * serialization agents and the CLI see. The export uses it for the kinds the
214
+ * grammar export has no body for (report / file / slides). Returns the single
215
+ * read item: a document shape carrying `kind`, or an `{ error }` item.
216
+ */
217
+ async readDocument(documentId) {
218
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/tools`, {
219
+ method: 'POST',
220
+ headers: {
221
+ 'Content-Type': 'application/json',
222
+ 'x-api-key': this.apiKey,
223
+ },
224
+ body: JSON.stringify({
225
+ tool: 'read',
226
+ params: { items: [{ type: 'document', documentId }] },
227
+ }),
228
+ });
229
+ if (!response.ok) {
230
+ const body = await response.json().catch(() => ({}));
231
+ throw new Error(formatApiError(response.status, body));
232
+ }
233
+ const data = await readJson(response);
234
+ const item = data.result?.items?.[0];
235
+ if (!item) {
236
+ throw new Error(`read returned no item for document ${documentId}`);
237
+ }
238
+ return item;
239
+ }
215
240
  /**
216
241
  * Build a `storageKey → signedDownloadUrl` map for a document's media, by
217
242
  * reusing the `get_document_content` export action (the blocks read, which
@@ -221,7 +246,7 @@ export class MoxnClient {
221
246
  * Returns an empty map when the document has no resolvable media.
222
247
  */
223
248
  async getDocumentMediaUrls(documentId) {
224
- const response = await fetch(`${this.apiUrl}/api/v1/kb/export`, {
249
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/export`, {
225
250
  method: 'POST',
226
251
  headers: {
227
252
  'Content-Type': 'application/json',
@@ -233,7 +258,7 @@ export class MoxnClient {
233
258
  const body = await response.text();
234
259
  throw new Error(`Failed to get document content ${documentId}: ${response.status} ${body}`);
235
260
  }
236
- const data = await response.json();
261
+ const data = await readJson(response);
237
262
  const doc = data.result?.document;
238
263
  const map = new Map();
239
264
  if (!doc)
@@ -258,7 +283,7 @@ export class MoxnClient {
258
283
  * onConflict 'update' → replace_document (full-set PUT)
259
284
  */
260
285
  async importMarkdown(input) {
261
- const response = await fetch(`${this.apiUrl}/api/v1/kb/import`, {
286
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/import`, {
262
287
  method: 'POST',
263
288
  headers: {
264
289
  'Content-Type': 'application/json',
@@ -269,20 +294,25 @@ export class MoxnClient {
269
294
  markdown: input.markdown,
270
295
  path: input.path,
271
296
  onConflict: input.onConflict,
297
+ // Applied by the server to a document this import CREATES.
298
+ ...(this.defaultPermission
299
+ ? { defaultPermission: this.defaultPermission }
300
+ : {}),
301
+ ...(this.aiAccess ? { aiAccess: this.aiAccess } : {}),
272
302
  }),
273
303
  });
274
304
  if (!response.ok) {
275
305
  const body = await response.json().catch(() => ({}));
276
306
  throw new Error(formatApiError(response.status, body));
277
307
  }
278
- const data = await response.json();
308
+ const data = await readJson(response);
279
309
  return data.result;
280
310
  }
281
311
  /**
282
312
  * Download a file from a URL to a local path.
283
313
  */
284
314
  async downloadFile(url, destPath) {
285
- const response = await fetch(url);
315
+ const response = await this.fetch(url);
286
316
  if (!response.ok) {
287
317
  throw new Error(`Failed to download ${url}: ${response.status}`);
288
318
  }
@@ -358,6 +388,45 @@ export class MoxnClient {
358
388
  filename: block.filename,
359
389
  };
360
390
  }
391
+ // Base64 payloads (extractors that inline-downloaded media) → upload.
392
+ if ((block.blockType === 'image' ||
393
+ block.blockType === 'document' ||
394
+ block.blockType === 'csv' ||
395
+ block.blockType === 'file') &&
396
+ block.type === 'base64' &&
397
+ block.base64) {
398
+ const data = Buffer.from(block.base64, 'base64');
399
+ const filename = ('filename' in block ? block.filename : undefined) || 'media';
400
+ const { key } = await this.uploadFile(data, block.mediaType || 'application/octet-stream', filename);
401
+ return { ...block, type: 'storage', key, base64: undefined };
402
+ }
403
+ // Remote URLs (e.g. expiring Notion signed URLs) → download + re-host,
404
+ // matching the durability the server-side blocksToTipTap re-hosting
405
+ // used to provide. A failed download keeps the URL ref (the grammar
406
+ // carries external URLs) rather than failing the document.
407
+ if ((block.blockType === 'image' ||
408
+ block.blockType === 'document' ||
409
+ block.blockType === 'csv' ||
410
+ block.blockType === 'file') &&
411
+ block.type === 'url' &&
412
+ block.url) {
413
+ try {
414
+ const response = await this.fetch(block.url);
415
+ if (!response.ok)
416
+ throw new Error(`HTTP ${response.status}`);
417
+ const data = Buffer.from(await response.arrayBuffer());
418
+ const filename = ('filename' in block ? block.filename : undefined) ||
419
+ new URL(block.url).pathname.split('/').pop() ||
420
+ 'media';
421
+ const { key } = await this.uploadFile(data, block.mediaType || 'application/octet-stream', filename);
422
+ return { ...block, type: 'storage', key, url: undefined };
423
+ }
424
+ catch (err) {
425
+ const msg = err instanceof Error ? err.message : String(err);
426
+ console.error(` ! media re-host failed, keeping external URL ref: ${block.url}: ${msg}`);
427
+ return block;
428
+ }
429
+ }
361
430
  return block;
362
431
  }));
363
432
  }
@@ -368,7 +437,7 @@ export class MoxnClient {
368
437
  // 1. Get presigned upload URL
369
438
  const { key, uploadUrl } = await this.getUploadUrl(mimeType, filename);
370
439
  // 2. PUT file to presigned URL
371
- const response = await fetch(uploadUrl, {
440
+ const response = await this.fetch(uploadUrl, {
372
441
  method: 'PUT',
373
442
  headers: { 'Content-Type': mimeType },
374
443
  body: new Uint8Array(data),
@@ -379,7 +448,7 @@ export class MoxnClient {
379
448
  return { key };
380
449
  }
381
450
  async getUploadUrl(type, filename) {
382
- const response = await fetch(`${this.apiUrl}/api/v1/kb/upload`, {
451
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/upload`, {
383
452
  method: 'POST',
384
453
  headers: {
385
454
  'Content-Type': 'application/json',
@@ -391,45 +460,9 @@ export class MoxnClient {
391
460
  const body = await response.text();
392
461
  throw new Error(`Upload URL request failed: ${response.status} ${body}`);
393
462
  }
394
- const data = await response.json();
463
+ const data = await readJson(response);
395
464
  return { key: data.key, uploadUrl: data.uploadUrl };
396
465
  }
397
- async createDocument(request) {
398
- const response = await fetch(`${this.apiUrl}/api/v1/kb/documents`, {
399
- method: 'POST',
400
- headers: {
401
- 'Content-Type': 'application/json',
402
- 'x-api-key': this.apiKey,
403
- },
404
- body: JSON.stringify(request),
405
- });
406
- if (!response.ok) {
407
- const body = await response.json().catch(() => ({}));
408
- if (response.status === 409 && body.documentId) {
409
- const error = new Error(body.error || 'Document already exists');
410
- error.documentId = body.documentId;
411
- error.branchId = body.branchId;
412
- throw error;
413
- }
414
- throw new Error(formatApiError(response.status, body));
415
- }
416
- return response.json();
417
- }
418
- async updateDocument(documentId, request) {
419
- const response = await fetch(`${this.apiUrl}/api/v1/kb/documents/${documentId}`, {
420
- method: 'PUT',
421
- headers: {
422
- 'Content-Type': 'application/json',
423
- 'x-api-key': this.apiKey,
424
- },
425
- body: JSON.stringify(request),
426
- });
427
- if (!response.ok) {
428
- const body = await response.json().catch(() => ({}));
429
- throw new Error(formatApiError(response.status, body));
430
- }
431
- return response.json();
432
- }
433
466
  // ──────────────────────────────────────────────
434
467
  // Database & tag methods (for Notion import)
435
468
  // ──────────────────────────────────────────────
@@ -437,7 +470,7 @@ export class MoxnClient {
437
470
  * Create a KB database.
438
471
  */
439
472
  async createDatabase(input) {
440
- const response = await fetch(`${this.apiUrl}/api/v1/kb/databases`, {
473
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/databases`, {
441
474
  method: 'POST',
442
475
  headers: {
443
476
  'Content-Type': 'application/json',
@@ -449,13 +482,13 @@ export class MoxnClient {
449
482
  const body = await response.json().catch(() => ({}));
450
483
  throw new Error(body.error || `Failed to create database: ${response.status}`);
451
484
  }
452
- return response.json();
485
+ return readJson(response);
453
486
  }
454
487
  /**
455
488
  * Add a column to a KB database.
456
489
  */
457
490
  async addDatabaseColumn(databaseId, input) {
458
- const response = await fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/columns`, {
491
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/columns`, {
459
492
  method: 'POST',
460
493
  headers: {
461
494
  'Content-Type': 'application/json',
@@ -467,13 +500,13 @@ export class MoxnClient {
467
500
  const body = await response.json().catch(() => ({}));
468
501
  throw new Error(body.error || `Failed to add column: ${response.status}`);
469
502
  }
470
- return response.json();
503
+ return readJson(response);
471
504
  }
472
505
  /**
473
506
  * Add a document to a KB database.
474
507
  */
475
508
  async addDocumentToDatabase(databaseId, documentId) {
476
- const response = await fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/documents`, {
509
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/documents`, {
477
510
  method: 'POST',
478
511
  headers: {
479
512
  'Content-Type': 'application/json',
@@ -493,7 +526,7 @@ export class MoxnClient {
493
526
  * Create a tag (with automatic ancestor creation).
494
527
  */
495
528
  async createTag(input) {
496
- const response = await fetch(`${this.apiUrl}/api/v1/kb/tags`, {
529
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/tags`, {
497
530
  method: 'POST',
498
531
  headers: {
499
532
  'Content-Type': 'application/json',
@@ -513,13 +546,13 @@ export class MoxnClient {
513
546
  }
514
547
  throw new Error(body.error || `Failed to create tag: ${response.status}`);
515
548
  }
516
- return response.json();
549
+ return readJson(response);
517
550
  }
518
551
  /**
519
552
  * Assign a tag to a document.
520
553
  */
521
554
  async assignTag(documentId, tagId, branchId) {
522
- const response = await fetch(`${this.apiUrl}/api/v1/kb/documents/${documentId}/tags`, {
555
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/documents/${documentId}/tags`, {
523
556
  method: 'POST',
524
557
  headers: {
525
558
  'Content-Type': 'application/json',
@@ -537,7 +570,7 @@ export class MoxnClient {
537
570
  * Returns { created, skipped, errors }.
538
571
  */
539
572
  async createReferences(documentId, references) {
540
- const response = await fetch(`${this.apiUrl}/api/v1/kb/documents/${documentId}/references`, {
573
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/documents/${documentId}/references`, {
541
574
  method: 'POST',
542
575
  headers: {
543
576
  'Content-Type': 'application/json',
@@ -549,51 +582,51 @@ export class MoxnClient {
549
582
  const body = await response.json().catch(() => ({}));
550
583
  throw new Error(body.error || `Failed to create references: ${response.status}`);
551
584
  }
552
- return response.json();
585
+ return readJson(response);
553
586
  }
554
587
  /**
555
588
  * List all KB databases for the tenant.
556
589
  */
557
590
  async listDatabases() {
558
- const response = await fetch(`${this.apiUrl}/api/v1/kb/databases`, {
591
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/databases`, {
559
592
  headers: { 'x-api-key': this.apiKey },
560
593
  });
561
594
  if (!response.ok) {
562
595
  const body = await response.json().catch(() => ({}));
563
596
  throw new Error(body.error || `Failed to list databases: ${response.status}`);
564
597
  }
565
- const data = await response.json();
598
+ const data = await readJson(response);
566
599
  return data.databases;
567
600
  }
568
601
  /**
569
602
  * Get fully resolved database with columns, options, and document property values.
570
603
  */
571
604
  async resolveDatabase(databaseId) {
572
- const response = await fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/resolve`, {
605
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/resolve`, {
573
606
  headers: { 'x-api-key': this.apiKey },
574
607
  });
575
608
  if (!response.ok) {
576
609
  const body = await response.json().catch(() => ({}));
577
610
  throw new Error(body.error || `Failed to resolve database: ${response.status}`);
578
611
  }
579
- return response.json();
612
+ return readJson(response);
580
613
  }
581
614
  /**
582
615
  * Get Notion database mapping for a KB database.
583
616
  */
584
617
  async getNotionDatabaseMapping(kbDatabaseId) {
585
- const response = await fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases/by-kb-database/${kbDatabaseId}`, { headers: { 'x-api-key': this.apiKey } });
618
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases/by-kb-database/${kbDatabaseId}`, { headers: { 'x-api-key': this.apiKey } });
586
619
  if (!response.ok) {
587
620
  const body = await response.json().catch(() => ({}));
588
621
  throw new Error(body.error || `Failed to get database mapping: ${response.status}`);
589
622
  }
590
- return response.json();
623
+ return readJson(response);
591
624
  }
592
625
  /**
593
626
  * Create or upsert a Notion database mapping.
594
627
  */
595
628
  async createNotionDatabaseMapping(input) {
596
- const response = await fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases`, {
629
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases`, {
597
630
  method: 'POST',
598
631
  headers: {
599
632
  'Content-Type': 'application/json',
@@ -605,13 +638,13 @@ export class MoxnClient {
605
638
  const body = await response.json().catch(() => ({}));
606
639
  throw new Error(body.error || `Failed to create database mapping: ${response.status}`);
607
640
  }
608
- return response.json();
641
+ return readJson(response);
609
642
  }
610
643
  /**
611
644
  * Set a scalar property value on a document in a database.
612
645
  */
613
646
  async setPropertyValue(databaseId, documentId, columnName, value) {
614
- const response = await fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/properties`, {
647
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/databases/${databaseId}/properties`, {
615
648
  method: 'POST',
616
649
  headers: {
617
650
  'Content-Type': 'application/json',
@@ -631,7 +664,7 @@ export class MoxnClient {
631
664
  * Create or upsert a Notion page → KB document mapping.
632
665
  */
633
666
  async createNotionPageMapping(input) {
634
- const response = await fetch(`${this.apiUrl}/api/v1/kb/notion-mappings`, {
667
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/notion-mappings`, {
635
668
  method: 'POST',
636
669
  headers: {
637
670
  'Content-Type': 'application/json',
@@ -649,14 +682,14 @@ export class MoxnClient {
649
682
  * Returns a Map of notionDatabaseId → kbDatabaseId.
650
683
  */
651
684
  async getAllDatabaseMappings() {
652
- const response = await fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases`, {
685
+ const response = await this.fetch(`${this.apiUrl}/api/v1/kb/notion-mappings/databases`, {
653
686
  headers: { 'x-api-key': this.apiKey },
654
687
  });
655
688
  if (!response.ok) {
656
689
  const body = await response.json().catch(() => ({}));
657
690
  throw new Error(body.error || `Failed to get database mappings: ${response.status}`);
658
691
  }
659
- const data = await response.json();
692
+ const data = await readJson(response);
660
693
  const map = new Map();
661
694
  for (const mapping of data.mappings) {
662
695
  map.set(mapping.notionDatabaseId, mapping.kbDatabaseId);
@@ -670,11 +703,4 @@ export class MoxnClient {
670
703
  const databases = await this.listDatabases();
671
704
  return databases.find((db) => db.id === databaseId) ?? null;
672
705
  }
673
- isConflictError(error) {
674
- return (error instanceof Error &&
675
- 'documentId' in error &&
676
- 'branchId' in error &&
677
- typeof error.documentId === 'string' &&
678
- typeof error.branchId === 'string');
679
- }
680
706
  }