@vue-skuilder/db 0.1.5 → 0.1.7

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.
Files changed (67) hide show
  1. package/CLAUDE.md +43 -0
  2. package/dist/core/index.d.mts +5 -5
  3. package/dist/core/index.d.ts +5 -5
  4. package/dist/core/index.js +2130 -7527
  5. package/dist/core/index.js.map +1 -1
  6. package/dist/core/index.mjs +2136 -7554
  7. package/dist/core/index.mjs.map +1 -1
  8. package/dist/{dataLayerProvider-B8wquRiB.d.mts → dataLayerProvider-6stCgDME.d.ts} +5 -1
  9. package/dist/{dataLayerProvider-DRjMZMaf.d.ts → dataLayerProvider-BbW9EnZK.d.mts} +5 -1
  10. package/dist/impl/couch/index.d.mts +6 -6
  11. package/dist/impl/couch/index.d.ts +6 -6
  12. package/dist/impl/couch/index.js +2040 -7420
  13. package/dist/impl/couch/index.js.map +1 -1
  14. package/dist/impl/couch/index.mjs +2046 -7447
  15. package/dist/impl/couch/index.mjs.map +1 -1
  16. package/dist/impl/static/index.d.mts +38 -54
  17. package/dist/impl/static/index.d.ts +38 -54
  18. package/dist/impl/static/index.js +812 -6206
  19. package/dist/impl/static/index.js.map +1 -1
  20. package/dist/impl/static/index.mjs +799 -6213
  21. package/dist/impl/static/index.mjs.map +1 -1
  22. package/dist/index.d.mts +244 -8
  23. package/dist/index.d.ts +244 -8
  24. package/dist/index.js +4885 -8853
  25. package/dist/index.js.map +1 -1
  26. package/dist/index.mjs +4485 -8487
  27. package/dist/index.mjs.map +1 -1
  28. package/dist/{types-B0GJsjOr.d.ts → types-BvzcRAys.d.ts} +14 -3
  29. package/dist/{types-DIgj8pP7.d.mts → types-CQQ80R5N.d.mts} +14 -3
  30. package/dist/{types-legacy-CTsJvvxI.d.mts → types-legacy-CtrmkOLu.d.mts} +3 -1
  31. package/dist/{types-legacy-CTsJvvxI.d.ts → types-legacy-CtrmkOLu.d.ts} +3 -1
  32. package/dist/{userDB-ZSwOXiYN.d.mts → userDB-7fM4tpgr.d.mts} +10 -3
  33. package/dist/{userDB-C5dcuRZs.d.ts → userDB-DUY63VMN.d.ts} +10 -3
  34. package/dist/util/packer/index.d.mts +43 -3
  35. package/dist/util/packer/index.d.ts +43 -3
  36. package/dist/util/packer/index.js +241 -36
  37. package/dist/util/packer/index.js.map +1 -1
  38. package/dist/util/packer/index.mjs +241 -36
  39. package/dist/util/packer/index.mjs.map +1 -1
  40. package/package.json +2 -2
  41. package/src/core/interfaces/courseDB.ts +1 -1
  42. package/src/core/interfaces/dataLayerProvider.ts +5 -0
  43. package/src/core/interfaces/userDB.ts +5 -0
  44. package/src/core/types/types-legacy.ts +2 -0
  45. package/src/factory.ts +25 -0
  46. package/src/impl/common/BaseUserDB.ts +87 -6
  47. package/src/impl/common/userDBHelpers.ts +11 -1
  48. package/src/impl/couch/PouchDataLayerProvider.ts +4 -0
  49. package/src/impl/couch/courseAPI.ts +31 -16
  50. package/src/impl/couch/courseDB.ts +8 -7
  51. package/src/impl/couch/courseLookupDB.ts +24 -0
  52. package/src/impl/static/StaticDataLayerProvider.ts +4 -0
  53. package/src/impl/static/StaticDataUnpacker.ts +175 -2
  54. package/src/impl/static/courseDB.ts +18 -0
  55. package/src/impl/static/index.ts +0 -1
  56. package/src/util/dataDirectory.test.ts +53 -0
  57. package/src/util/dataDirectory.ts +52 -0
  58. package/src/util/index.ts +3 -0
  59. package/src/util/migrator/FileSystemAdapter.ts +59 -0
  60. package/src/util/migrator/StaticToCouchDBMigrator.ts +707 -0
  61. package/src/util/migrator/index.ts +18 -0
  62. package/src/util/migrator/types.ts +84 -0
  63. package/src/util/migrator/validation.ts +517 -0
  64. package/src/util/packer/CouchDBToStaticPacker.ts +320 -49
  65. package/src/util/packer/types.ts +13 -1
  66. package/src/util/tuiLogger.ts +139 -0
  67. package/src/impl/static/userDB.ts +0 -179
@@ -0,0 +1,707 @@
1
+ // packages/db/src/util/migrator/StaticToCouchDBMigrator.ts
2
+
3
+ import { logger } from '../logger';
4
+ import { StaticCourseManifest, ChunkMetadata, DesignDocument } from '../packer/types';
5
+ import {
6
+ MigrationOptions,
7
+ MigrationResult,
8
+ DEFAULT_MIGRATION_OPTIONS,
9
+ DocumentCounts,
10
+ RestoreProgress,
11
+ AggregatedDocument,
12
+ RestoreResults,
13
+ AttachmentUploadResult,
14
+ } from './types';
15
+ import { validateStaticCourse, validateMigration } from './validation';
16
+ import { FileSystemAdapter, FileSystemError } from './FileSystemAdapter';
17
+
18
+ // Fallback for environments without FileSystemAdapter (backward compatibility)
19
+ let nodeFS: any = null;
20
+ let nodePath: any = null;
21
+ try {
22
+ if (typeof window === 'undefined' && typeof process !== 'undefined' && process.versions?.node) {
23
+ nodeFS = eval('require')('fs');
24
+ nodePath = eval('require')('path');
25
+ nodeFS.promises = nodeFS.promises || eval('require')('fs').promises;
26
+ }
27
+ } catch {
28
+ // fs not available, will use fetch
29
+ }
30
+
31
+ export class StaticToCouchDBMigrator {
32
+ private options: MigrationOptions;
33
+ private progressCallback?: (progress: RestoreProgress) => void;
34
+ private fs?: FileSystemAdapter;
35
+
36
+ constructor(options: Partial<MigrationOptions> = {}, fileSystemAdapter?: FileSystemAdapter) {
37
+ this.options = {
38
+ ...DEFAULT_MIGRATION_OPTIONS,
39
+ ...options,
40
+ };
41
+ this.fs = fileSystemAdapter;
42
+ }
43
+
44
+ /**
45
+ * Set a progress callback to receive updates during migration
46
+ */
47
+ setProgressCallback(callback: (progress: RestoreProgress) => void): void {
48
+ this.progressCallback = callback;
49
+ }
50
+
51
+ /**
52
+ * Migrate a static course to CouchDB
53
+ */
54
+ async migrateCourse(staticPath: string, targetDB: PouchDB.Database): Promise<MigrationResult> {
55
+ const startTime = Date.now();
56
+ const result: MigrationResult = {
57
+ success: false,
58
+ documentsRestored: 0,
59
+ attachmentsRestored: 0,
60
+ designDocsRestored: 0,
61
+ courseConfigRestored: 0,
62
+ errors: [] as string[],
63
+ warnings: [] as string[],
64
+ migrationTime: 0,
65
+ };
66
+
67
+ try {
68
+ logger.info(`Starting migration from ${staticPath} to CouchDB`);
69
+
70
+ // Phase 1: Validate static course
71
+ this.reportProgress('manifest', 0, 1, 'Validating static course...');
72
+ const validation = await validateStaticCourse(staticPath, this.fs);
73
+ if (!validation.valid) {
74
+ result.errors.push(...validation.errors);
75
+ throw new Error(`Static course validation failed: ${validation.errors.join(', ')}`);
76
+ }
77
+ result.warnings.push(...validation.warnings);
78
+
79
+ // Phase 2: Load manifest
80
+ this.reportProgress('manifest', 1, 1, 'Loading course manifest...');
81
+ const manifest = await this.loadManifest(staticPath);
82
+ logger.info(`Loaded manifest for course: ${manifest.courseId} (${manifest.courseName})`);
83
+
84
+ // Phase 3: Restore design documents
85
+ this.reportProgress(
86
+ 'design_docs',
87
+ 0,
88
+ manifest.designDocs.length,
89
+ 'Restoring design documents...'
90
+ );
91
+ const designDocResults = await this.restoreDesignDocuments(manifest.designDocs, targetDB);
92
+ result.designDocsRestored = designDocResults.restored;
93
+ result.errors.push(...designDocResults.errors);
94
+ result.warnings.push(...designDocResults.warnings);
95
+
96
+ // Phase 3.5: Restore CourseConfig
97
+ this.reportProgress('course_config', 0, 1, 'Restoring CourseConfig document...');
98
+ const courseConfigResults = await this.restoreCourseConfig(manifest, targetDB);
99
+ result.courseConfigRestored = courseConfigResults.restored;
100
+ result.errors.push(...courseConfigResults.errors);
101
+ result.warnings.push(...courseConfigResults.warnings);
102
+ this.reportProgress('course_config', 1, 1, 'CourseConfig document restored');
103
+
104
+ // Phase 4: Aggregate and restore documents
105
+ const expectedCounts = this.calculateExpectedCounts(manifest);
106
+ this.reportProgress(
107
+ 'documents',
108
+ 0,
109
+ manifest.documentCount,
110
+ 'Aggregating documents from chunks...'
111
+ );
112
+ const documents = await this.aggregateDocuments(staticPath, manifest);
113
+
114
+ // Filter out CourseConfig documents to prevent conflicts with Phase 3.5
115
+ const filteredDocuments = documents.filter((doc) => doc._id !== 'CourseConfig');
116
+ if (documents.length !== filteredDocuments.length) {
117
+ result.warnings.push(
118
+ `Filtered out ${documents.length - filteredDocuments.length} CourseConfig document(s) from chunks to prevent conflicts`
119
+ );
120
+ }
121
+
122
+ this.reportProgress(
123
+ 'documents',
124
+ filteredDocuments.length,
125
+ manifest.documentCount,
126
+ 'Uploading documents to CouchDB...'
127
+ );
128
+ const docResults = await this.uploadDocuments(filteredDocuments, targetDB);
129
+ result.documentsRestored = docResults.restored;
130
+ result.errors.push(...docResults.errors);
131
+ result.warnings.push(...docResults.warnings);
132
+
133
+ // Phase 5: Upload attachments
134
+ const docsWithAttachments = documents.filter(
135
+ (doc) => doc._attachments && Object.keys(doc._attachments).length > 0
136
+ );
137
+ this.reportProgress('attachments', 0, docsWithAttachments.length, 'Uploading attachments...');
138
+ const attachmentResults = await this.uploadAttachments(
139
+ staticPath,
140
+ docsWithAttachments,
141
+ targetDB
142
+ );
143
+ result.attachmentsRestored = attachmentResults.restored;
144
+ result.errors.push(...attachmentResults.errors);
145
+ result.warnings.push(...attachmentResults.warnings);
146
+
147
+ // Phase 6: Validation (if enabled)
148
+ if (this.options.validateRoundTrip) {
149
+ this.reportProgress('validation', 0, 1, 'Validating migration...');
150
+ const validationResult = await validateMigration(targetDB, expectedCounts, manifest);
151
+ if (!validationResult.valid) {
152
+ result.warnings.push('Migration validation found issues');
153
+ validationResult.issues.forEach((issue) => {
154
+ if (issue.type === 'error') {
155
+ result.errors.push(`Validation: ${issue.message}`);
156
+ } else {
157
+ result.warnings.push(`Validation: ${issue.message}`);
158
+ }
159
+ });
160
+ }
161
+ this.reportProgress('validation', 1, 1, 'Migration validation completed');
162
+ }
163
+
164
+ // Success!
165
+ result.success = result.errors.length === 0;
166
+ result.migrationTime = Date.now() - startTime;
167
+
168
+ logger.info(`Migration completed in ${result.migrationTime}ms`);
169
+ logger.info(`Documents restored: ${result.documentsRestored}`);
170
+ logger.info(`Attachments restored: ${result.attachmentsRestored}`);
171
+ logger.info(`Design docs restored: ${result.designDocsRestored}`);
172
+ logger.info(`CourseConfig restored: ${result.courseConfigRestored}`);
173
+
174
+ if (result.errors.length > 0) {
175
+ logger.error(`Migration completed with ${result.errors.length} errors`);
176
+ }
177
+ if (result.warnings.length > 0) {
178
+ logger.warn(`Migration completed with ${result.warnings.length} warnings`);
179
+ }
180
+ } catch (error) {
181
+ result.success = false;
182
+ result.migrationTime = Date.now() - startTime;
183
+ const errorMessage = error instanceof Error ? error.message : String(error);
184
+ result.errors.push(`Migration failed: ${errorMessage}`);
185
+ logger.error('Migration failed:', error);
186
+
187
+ // Cleanup on failure if requested
188
+ if (this.options.cleanupOnFailure) {
189
+ try {
190
+ await this.cleanupFailedMigration(targetDB);
191
+ } catch (cleanupError) {
192
+ logger.error('Failed to cleanup after migration failure:', cleanupError);
193
+ result.warnings.push('Failed to cleanup after migration failure');
194
+ }
195
+ }
196
+ }
197
+
198
+ return result;
199
+ }
200
+
201
+ /**
202
+ * Load and parse the manifest file
203
+ */
204
+ private async loadManifest(staticPath: string): Promise<StaticCourseManifest> {
205
+ try {
206
+ let manifestContent: string;
207
+ let manifestPath: string;
208
+
209
+ if (this.fs) {
210
+ // Use injected file system adapter (preferred)
211
+ manifestPath = this.fs.joinPath(staticPath, 'manifest.json');
212
+ manifestContent = await this.fs.readFile(manifestPath);
213
+ } else {
214
+ // Fallback to legacy behavior for backward compatibility
215
+ manifestPath =
216
+ nodeFS && nodePath
217
+ ? nodePath.join(staticPath, 'manifest.json')
218
+ : `${staticPath}/manifest.json`;
219
+
220
+ if (nodeFS && this.isLocalPath(staticPath)) {
221
+ // Node.js file system access
222
+ manifestContent = await nodeFS.promises.readFile(manifestPath, 'utf8');
223
+ } else {
224
+ // Browser/fetch access
225
+ const response = await fetch(manifestPath);
226
+ if (!response.ok) {
227
+ throw new Error(`Failed to fetch manifest: ${response.status} ${response.statusText}`);
228
+ }
229
+ manifestContent = await response.text();
230
+ }
231
+ }
232
+
233
+ const manifest: StaticCourseManifest = JSON.parse(manifestContent);
234
+
235
+ // Basic validation
236
+ if (!manifest.version || !manifest.courseId || !manifest.chunks) {
237
+ throw new Error('Invalid manifest structure');
238
+ }
239
+
240
+ return manifest;
241
+ } catch (error) {
242
+ const errorMessage =
243
+ error instanceof FileSystemError
244
+ ? error.message
245
+ : `Failed to load manifest: ${error instanceof Error ? error.message : String(error)}`;
246
+ throw new Error(errorMessage);
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Restore design documents to CouchDB
252
+ */
253
+ private async restoreDesignDocuments(
254
+ designDocs: DesignDocument[],
255
+ db: PouchDB.Database
256
+ ): Promise<{ restored: number; errors: string[]; warnings: string[] }> {
257
+ const result = { restored: 0, errors: [] as string[], warnings: [] as string[] };
258
+
259
+ for (let i = 0; i < designDocs.length; i++) {
260
+ const designDoc = designDocs[i];
261
+ this.reportProgress('design_docs', i, designDocs.length, `Restoring ${designDoc._id}...`);
262
+
263
+ try {
264
+ // Check if design document already exists
265
+ let existingDoc;
266
+ try {
267
+ existingDoc = await db.get(designDoc._id);
268
+ } catch {
269
+ // Document doesn't exist, which is fine
270
+ }
271
+
272
+ // Prepare the document for insertion
273
+ const docToInsert: any = {
274
+ _id: designDoc._id,
275
+ views: designDoc.views,
276
+ };
277
+
278
+ // If document exists, include the revision for update
279
+ if (existingDoc) {
280
+ docToInsert._rev = existingDoc._rev;
281
+ logger.debug(`Updating existing design document: ${designDoc._id}`);
282
+ } else {
283
+ logger.debug(`Creating new design document: ${designDoc._id}`);
284
+ }
285
+
286
+ await db.put(docToInsert);
287
+ result.restored++;
288
+ } catch (error) {
289
+ const errorMessage = `Failed to restore design document ${designDoc._id}: ${error instanceof Error ? error.message : String(error)}`;
290
+ result.errors.push(errorMessage);
291
+ logger.error(errorMessage);
292
+ }
293
+ }
294
+
295
+ this.reportProgress(
296
+ 'design_docs',
297
+ designDocs.length,
298
+ designDocs.length,
299
+ `Restored ${result.restored} design documents`
300
+ );
301
+ return result;
302
+ }
303
+
304
+ /**
305
+ * Aggregate documents from all chunks
306
+ */
307
+ private async aggregateDocuments(
308
+ staticPath: string,
309
+ manifest: StaticCourseManifest
310
+ ): Promise<AggregatedDocument[]> {
311
+ const allDocuments: AggregatedDocument[] = [];
312
+ const documentMap = new Map<string, AggregatedDocument>(); // For deduplication
313
+
314
+ for (let i = 0; i < manifest.chunks.length; i++) {
315
+ const chunk = manifest.chunks[i];
316
+ this.reportProgress(
317
+ 'documents',
318
+ allDocuments.length,
319
+ manifest.documentCount,
320
+ `Loading chunk ${chunk.id}...`
321
+ );
322
+
323
+ try {
324
+ const documents = await this.loadChunk(staticPath, chunk);
325
+
326
+ for (const doc of documents) {
327
+ if (!doc._id) {
328
+ logger.warn(`Document without _id found in chunk ${chunk.id}, skipping`);
329
+ continue;
330
+ }
331
+
332
+ // Handle potential duplicates (shouldn't happen, but be safe)
333
+ if (documentMap.has(doc._id)) {
334
+ logger.warn(`Duplicate document ID found: ${doc._id}, using latest version`);
335
+ }
336
+
337
+ documentMap.set(doc._id, doc);
338
+ }
339
+ } catch (error) {
340
+ throw new Error(
341
+ `Failed to load chunk ${chunk.id}: ${error instanceof Error ? error.message : String(error)}`
342
+ );
343
+ }
344
+ }
345
+
346
+ // Convert map to array
347
+ allDocuments.push(...documentMap.values());
348
+
349
+ logger.info(
350
+ `Aggregated ${allDocuments.length} unique documents from ${manifest.chunks.length} chunks`
351
+ );
352
+ return allDocuments;
353
+ }
354
+
355
+ /**
356
+ * Load documents from a single chunk file
357
+ */
358
+ private async loadChunk(staticPath: string, chunk: ChunkMetadata): Promise<any[]> {
359
+ try {
360
+ let chunkContent: string;
361
+ let chunkPath: string;
362
+
363
+ if (this.fs) {
364
+ // Use injected file system adapter (preferred)
365
+ chunkPath = this.fs.joinPath(staticPath, chunk.path);
366
+ chunkContent = await this.fs.readFile(chunkPath);
367
+ } else {
368
+ // Fallback to legacy behavior for backward compatibility
369
+ chunkPath =
370
+ nodeFS && nodePath
371
+ ? nodePath.join(staticPath, chunk.path)
372
+ : `${staticPath}/${chunk.path}`;
373
+
374
+ if (nodeFS && this.isLocalPath(staticPath)) {
375
+ // Node.js file system access
376
+ chunkContent = await nodeFS.promises.readFile(chunkPath, 'utf8');
377
+ } else {
378
+ // Browser/fetch access
379
+ const response = await fetch(chunkPath);
380
+ if (!response.ok) {
381
+ throw new Error(`Failed to fetch chunk: ${response.status} ${response.statusText}`);
382
+ }
383
+ chunkContent = await response.text();
384
+ }
385
+ }
386
+
387
+ const documents = JSON.parse(chunkContent);
388
+
389
+ if (!Array.isArray(documents)) {
390
+ throw new Error('Chunk file does not contain an array of documents');
391
+ }
392
+
393
+ return documents;
394
+ } catch (error) {
395
+ const errorMessage =
396
+ error instanceof FileSystemError
397
+ ? error.message
398
+ : `Failed to load chunk: ${error instanceof Error ? error.message : String(error)}`;
399
+ throw new Error(errorMessage);
400
+ }
401
+ }
402
+
403
+ /**
404
+ * Upload documents to CouchDB in batches
405
+ */
406
+ private async uploadDocuments(
407
+ documents: AggregatedDocument[],
408
+ db: PouchDB.Database
409
+ ): Promise<{ restored: number; errors: string[]; warnings: string[] }> {
410
+ const result = { restored: 0, errors: [] as string[], warnings: [] as string[] };
411
+ const batchSize = this.options.chunkBatchSize;
412
+
413
+ for (let i = 0; i < documents.length; i += batchSize) {
414
+ const batch = documents.slice(i, i + batchSize);
415
+ this.reportProgress(
416
+ 'documents',
417
+ i,
418
+ documents.length,
419
+ `Uploading batch ${Math.floor(i / batchSize) + 1}...`
420
+ );
421
+
422
+ try {
423
+ // Prepare documents for bulk insert
424
+ const docsToInsert = batch.map((doc) => {
425
+ const cleanDoc = { ...doc };
426
+ // Remove _rev if present (CouchDB will assign new revision)
427
+ delete cleanDoc._rev;
428
+
429
+ return cleanDoc;
430
+ });
431
+
432
+ const bulkResult = await db.bulkDocs(docsToInsert);
433
+
434
+ // Process results
435
+ for (let j = 0; j < bulkResult.length; j++) {
436
+ const docResult = bulkResult[j];
437
+ const originalDoc = batch[j];
438
+
439
+ if ('error' in docResult) {
440
+ const errorMessage = `Failed to upload document ${originalDoc._id}: ${docResult.error} - ${docResult.reason}`;
441
+ result.errors.push(errorMessage);
442
+ logger.error(errorMessage);
443
+ } else {
444
+ result.restored++;
445
+ }
446
+ }
447
+ } catch (error) {
448
+ let errorMessage: string;
449
+ if (error instanceof Error) {
450
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${error.message}`;
451
+ } else if (error && typeof error === 'object' && 'message' in error) {
452
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${(error as any).message}`;
453
+ } else {
454
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${JSON.stringify(error)}`;
455
+ }
456
+ result.errors.push(errorMessage);
457
+ logger.error(errorMessage);
458
+ }
459
+ }
460
+
461
+ this.reportProgress(
462
+ 'documents',
463
+ documents.length,
464
+ documents.length,
465
+ `Uploaded ${result.restored} documents`
466
+ );
467
+ return result;
468
+ }
469
+
470
+ /**
471
+ * Upload attachments from filesystem to CouchDB
472
+ */
473
+ private async uploadAttachments(
474
+ staticPath: string,
475
+ documents: AggregatedDocument[],
476
+ db: PouchDB.Database
477
+ ): Promise<{ restored: number; errors: string[]; warnings: string[] }> {
478
+ const result = { restored: 0, errors: [] as string[], warnings: [] as string[] };
479
+ let processedDocs = 0;
480
+
481
+ for (const doc of documents) {
482
+ this.reportProgress(
483
+ 'attachments',
484
+ processedDocs,
485
+ documents.length,
486
+ `Processing attachments for ${doc._id}...`
487
+ );
488
+ processedDocs++;
489
+
490
+ if (!doc._attachments) {
491
+ continue;
492
+ }
493
+
494
+ for (const [attachmentName, attachmentMeta] of Object.entries(doc._attachments)) {
495
+ try {
496
+ const uploadResult = await this.uploadSingleAttachment(
497
+ staticPath,
498
+ doc._id,
499
+ attachmentName,
500
+ attachmentMeta as any,
501
+ db
502
+ );
503
+
504
+ if (uploadResult.success) {
505
+ result.restored++;
506
+ } else {
507
+ result.errors.push(uploadResult.error || 'Unknown attachment upload error');
508
+ }
509
+ } catch (error) {
510
+ const errorMessage = `Failed to upload attachment ${doc._id}/${attachmentName}: ${error instanceof Error ? error.message : String(error)}`;
511
+ result.errors.push(errorMessage);
512
+ logger.error(errorMessage);
513
+ }
514
+ }
515
+ }
516
+
517
+ this.reportProgress(
518
+ 'attachments',
519
+ documents.length,
520
+ documents.length,
521
+ `Uploaded ${result.restored} attachments`
522
+ );
523
+ return result;
524
+ }
525
+
526
+ /**
527
+ * Upload a single attachment file
528
+ */
529
+ private async uploadSingleAttachment(
530
+ staticPath: string,
531
+ docId: string,
532
+ attachmentName: string,
533
+ attachmentMeta: any,
534
+ db: PouchDB.Database
535
+ ): Promise<AttachmentUploadResult> {
536
+ const result: AttachmentUploadResult = {
537
+ success: false,
538
+ attachmentName,
539
+ docId,
540
+ };
541
+
542
+ try {
543
+ // Get the file path from the attachment metadata
544
+ if (!attachmentMeta.path) {
545
+ result.error = 'Attachment metadata missing file path';
546
+ return result;
547
+ }
548
+
549
+ // Load the attachment data
550
+ let attachmentData: ArrayBuffer | Buffer;
551
+ let attachmentPath: string;
552
+
553
+ if (this.fs) {
554
+ // Use injected file system adapter (preferred)
555
+ attachmentPath = this.fs.joinPath(staticPath, attachmentMeta.path);
556
+ attachmentData = await this.fs.readBinary(attachmentPath);
557
+ } else {
558
+ // Fallback to legacy behavior for backward compatibility
559
+ attachmentPath =
560
+ nodeFS && nodePath
561
+ ? nodePath.join(staticPath, attachmentMeta.path)
562
+ : `${staticPath}/${attachmentMeta.path}`;
563
+
564
+ if (nodeFS && this.isLocalPath(staticPath)) {
565
+ // Node.js file system access
566
+ attachmentData = await nodeFS.promises.readFile(attachmentPath);
567
+ } else {
568
+ // Browser/fetch access
569
+ const response = await fetch(attachmentPath);
570
+ if (!response.ok) {
571
+ result.error = `Failed to fetch attachment: ${response.status} ${response.statusText}`;
572
+ return result;
573
+ }
574
+ attachmentData = await response.arrayBuffer();
575
+ }
576
+ }
577
+
578
+ // Upload to CouchDB
579
+ await db.putAttachment(
580
+ docId,
581
+ attachmentName,
582
+ attachmentData as any, // PouchDB accepts both ArrayBuffer and Buffer
583
+ attachmentMeta.content_type
584
+ );
585
+
586
+ result.success = true;
587
+ } catch (error) {
588
+ result.error = error instanceof Error ? error.message : String(error);
589
+ }
590
+
591
+ return result;
592
+ }
593
+
594
+ /**
595
+ * Restore CourseConfig document from manifest
596
+ */
597
+ private async restoreCourseConfig(
598
+ manifest: StaticCourseManifest,
599
+ targetDB: PouchDB.Database
600
+ ): Promise<RestoreResults> {
601
+ const results: RestoreResults = {
602
+ restored: 0,
603
+ errors: [],
604
+ warnings: [],
605
+ };
606
+
607
+ try {
608
+ // Validate courseConfig exists
609
+ if (!manifest.courseConfig) {
610
+ results.warnings.push(
611
+ 'No courseConfig found in manifest, skipping CourseConfig document creation'
612
+ );
613
+ return results;
614
+ }
615
+
616
+ // Create CourseConfig document
617
+ const courseConfigDoc: { [key: string]: any; _id: string; _rev?: string } = {
618
+ _id: 'CourseConfig',
619
+ ...manifest.courseConfig,
620
+ courseID: manifest.courseId,
621
+ };
622
+ delete courseConfigDoc._rev;
623
+
624
+ // Upload to CouchDB
625
+ await targetDB.put(courseConfigDoc);
626
+ results.restored = 1;
627
+
628
+ logger.info(`CourseConfig document created for course: ${manifest.courseId}`);
629
+ } catch (error) {
630
+ const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
631
+ results.errors.push(`Failed to restore CourseConfig: ${errorMessage}`);
632
+ logger.error('CourseConfig restoration failed:', error);
633
+ }
634
+
635
+ return results;
636
+ }
637
+
638
+ /**
639
+ * Calculate expected document counts from manifest
640
+ */
641
+ private calculateExpectedCounts(manifest: StaticCourseManifest): DocumentCounts {
642
+ const counts: DocumentCounts = {};
643
+
644
+ // Count documents by type from chunks
645
+ for (const chunk of manifest.chunks) {
646
+ counts[chunk.docType] = (counts[chunk.docType] || 0) + chunk.documentCount;
647
+ }
648
+
649
+ // Count design documents
650
+ if (manifest.designDocs.length > 0) {
651
+ counts['_design'] = manifest.designDocs.length;
652
+ }
653
+
654
+ return counts;
655
+ }
656
+
657
+ /**
658
+ * Clean up database after failed migration
659
+ */
660
+ private async cleanupFailedMigration(db: PouchDB.Database): Promise<void> {
661
+ logger.info('Cleaning up failed migration...');
662
+
663
+ try {
664
+ // Get all documents and delete them
665
+ const allDocs = await db.allDocs();
666
+ const docsToDelete = allDocs.rows.map((row) => ({
667
+ _id: row.id,
668
+ _rev: row.value.rev,
669
+ _deleted: true,
670
+ }));
671
+
672
+ if (docsToDelete.length > 0) {
673
+ await db.bulkDocs(docsToDelete);
674
+ logger.info(`Cleaned up ${docsToDelete.length} documents from failed migration`);
675
+ }
676
+ } catch (error) {
677
+ logger.error('Failed to cleanup documents:', error);
678
+ throw error;
679
+ }
680
+ }
681
+
682
+ /**
683
+ * Report progress to callback if available
684
+ */
685
+ private reportProgress(
686
+ phase: RestoreProgress['phase'],
687
+ current: number,
688
+ total: number,
689
+ message: string
690
+ ): void {
691
+ if (this.progressCallback) {
692
+ this.progressCallback({
693
+ phase,
694
+ current,
695
+ total,
696
+ message,
697
+ });
698
+ }
699
+ }
700
+
701
+ /**
702
+ * Check if a path is a local file path (vs URL)
703
+ */
704
+ private isLocalPath(path: string): boolean {
705
+ return !path.startsWith('http://') && !path.startsWith('https://');
706
+ }
707
+ }