@vue-skuilder/db 0.1.6 → 0.1.8-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.
Files changed (70) hide show
  1. package/dist/{SyncStrategy-DnJRj-Xp.d.mts → SyncStrategy-CyATpyLQ.d.mts} +6 -0
  2. package/dist/{SyncStrategy-DnJRj-Xp.d.ts → SyncStrategy-CyATpyLQ.d.ts} +6 -0
  3. package/dist/core/index.d.mts +5 -5
  4. package/dist/core/index.d.ts +5 -5
  5. package/dist/core/index.js +825 -762
  6. package/dist/core/index.js.map +1 -1
  7. package/dist/core/index.mjs +812 -750
  8. package/dist/core/index.mjs.map +1 -1
  9. package/dist/{dataLayerProvider-BZmLyBVw.d.mts → dataLayerProvider-BInqI_RF.d.mts} +1 -1
  10. package/dist/{dataLayerProvider-BuntXkCs.d.ts → dataLayerProvider-DqtNroSh.d.ts} +1 -1
  11. package/dist/impl/couch/index.d.mts +6 -6
  12. package/dist/impl/couch/index.d.ts +6 -6
  13. package/dist/impl/couch/index.js +2261 -2081
  14. package/dist/impl/couch/index.js.map +1 -1
  15. package/dist/impl/couch/index.mjs +2274 -2095
  16. package/dist/impl/couch/index.mjs.map +1 -1
  17. package/dist/impl/static/index.d.mts +8 -6
  18. package/dist/impl/static/index.d.ts +8 -6
  19. package/dist/impl/static/index.js +524 -1064
  20. package/dist/impl/static/index.js.map +1 -1
  21. package/dist/impl/static/index.mjs +515 -1058
  22. package/dist/impl/static/index.mjs.map +1 -1
  23. package/dist/index-CLL31bEy.d.ts +137 -0
  24. package/dist/index-CUNnL38E.d.mts +137 -0
  25. package/dist/index.d.mts +200 -9
  26. package/dist/index.d.ts +200 -9
  27. package/dist/index.js +4123 -2820
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +4119 -2830
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/{types-D6SnlHPm.d.ts → types-BefDGkKa.d.ts} +1 -1
  32. package/dist/{types-DPRvCrIk.d.mts → types-DC-ckZug.d.mts} +1 -1
  33. package/dist/{types-legacy-WPe8CtO-.d.mts → types-legacy-Birv-Jx6.d.mts} +2 -2
  34. package/dist/{types-legacy-WPe8CtO-.d.ts → types-legacy-Birv-Jx6.d.ts} +2 -2
  35. package/dist/{userDB-D9EuWTp1.d.ts → userDB-C33Hzjgn.d.mts} +11 -4
  36. package/dist/{userDB-31gsvxyd.d.mts → userDB-DusL7OXe.d.ts} +11 -4
  37. package/dist/util/packer/index.d.mts +3 -63
  38. package/dist/util/packer/index.d.ts +3 -63
  39. package/dist/util/packer/index.js +53 -1
  40. package/dist/util/packer/index.js.map +1 -1
  41. package/dist/util/packer/index.mjs +53 -1
  42. package/dist/util/packer/index.mjs.map +1 -1
  43. package/package.json +7 -4
  44. package/src/core/types/types-legacy.ts +13 -1
  45. package/src/core/types/user.ts +9 -2
  46. package/src/core/util/index.ts +5 -4
  47. package/src/factory.ts +25 -0
  48. package/src/impl/common/BaseUserDB.ts +62 -28
  49. package/src/impl/common/SyncStrategy.ts +7 -0
  50. package/src/impl/common/index.ts +0 -1
  51. package/src/impl/common/userDBHelpers.ts +15 -5
  52. package/src/impl/couch/CouchDBSyncStrategy.ts +10 -0
  53. package/src/impl/couch/courseAPI.ts +7 -6
  54. package/src/impl/couch/courseLookupDB.ts +24 -0
  55. package/src/impl/couch/index.ts +10 -5
  56. package/src/impl/couch/updateQueue.ts +12 -8
  57. package/src/impl/couch/user-course-relDB.ts +17 -27
  58. package/src/impl/static/NoOpSyncStrategy.ts +5 -0
  59. package/src/impl/static/StaticDataUnpacker.ts +18 -36
  60. package/src/impl/static/courseDB.ts +135 -17
  61. package/src/util/dataDirectory.test.ts +53 -0
  62. package/src/util/dataDirectory.ts +52 -0
  63. package/src/util/index.ts +3 -0
  64. package/src/util/migrator/FileSystemAdapter.ts +79 -0
  65. package/src/util/migrator/StaticToCouchDBMigrator.ts +713 -0
  66. package/src/util/migrator/index.ts +18 -0
  67. package/src/util/migrator/types.ts +84 -0
  68. package/src/util/migrator/validation.ts +517 -0
  69. package/src/util/packer/CouchDBToStaticPacker.ts +92 -2
  70. package/src/util/tuiLogger.ts +139 -0
@@ -0,0 +1,713 @@
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
+ // Remove _attachments - these are uploaded separately in Phase 5
429
+ delete cleanDoc._attachments;
430
+
431
+ return cleanDoc;
432
+ });
433
+
434
+ const bulkResult = await db.bulkDocs(docsToInsert);
435
+
436
+ // Process results
437
+ for (let j = 0; j < bulkResult.length; j++) {
438
+ const docResult = bulkResult[j];
439
+ const originalDoc = batch[j];
440
+
441
+ if ('error' in docResult) {
442
+ const errorMessage = `Failed to upload document ${originalDoc._id}: ${docResult.error} - ${docResult.reason}`;
443
+ result.errors.push(errorMessage);
444
+ logger.error(errorMessage);
445
+ } else {
446
+ result.restored++;
447
+ }
448
+ }
449
+ } catch (error) {
450
+ let errorMessage: string;
451
+ if (error instanceof Error) {
452
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${error.message}`;
453
+ } else if (error && typeof error === 'object' && 'message' in error) {
454
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${(error as any).message}`;
455
+ } else {
456
+ errorMessage = `Failed to upload document batch starting at index ${i}: ${JSON.stringify(error)}`;
457
+ }
458
+ result.errors.push(errorMessage);
459
+ logger.error(errorMessage);
460
+ }
461
+ }
462
+
463
+ this.reportProgress(
464
+ 'documents',
465
+ documents.length,
466
+ documents.length,
467
+ `Uploaded ${result.restored} documents`
468
+ );
469
+ return result;
470
+ }
471
+
472
+ /**
473
+ * Upload attachments from filesystem to CouchDB
474
+ */
475
+ private async uploadAttachments(
476
+ staticPath: string,
477
+ documents: AggregatedDocument[],
478
+ db: PouchDB.Database
479
+ ): Promise<{ restored: number; errors: string[]; warnings: string[] }> {
480
+ const result = { restored: 0, errors: [] as string[], warnings: [] as string[] };
481
+ let processedDocs = 0;
482
+
483
+ for (const doc of documents) {
484
+ this.reportProgress(
485
+ 'attachments',
486
+ processedDocs,
487
+ documents.length,
488
+ `Processing attachments for ${doc._id}...`
489
+ );
490
+ processedDocs++;
491
+
492
+ if (!doc._attachments) {
493
+ continue;
494
+ }
495
+
496
+ for (const [attachmentName, attachmentMeta] of Object.entries(doc._attachments)) {
497
+ try {
498
+ const uploadResult = await this.uploadSingleAttachment(
499
+ staticPath,
500
+ doc._id,
501
+ attachmentName,
502
+ attachmentMeta as any,
503
+ db
504
+ );
505
+
506
+ if (uploadResult.success) {
507
+ result.restored++;
508
+ } else {
509
+ result.errors.push(uploadResult.error || 'Unknown attachment upload error');
510
+ }
511
+ } catch (error) {
512
+ const errorMessage = `Failed to upload attachment ${doc._id}/${attachmentName}: ${error instanceof Error ? error.message : String(error)}`;
513
+ result.errors.push(errorMessage);
514
+ logger.error(errorMessage);
515
+ }
516
+ }
517
+ }
518
+
519
+ this.reportProgress(
520
+ 'attachments',
521
+ documents.length,
522
+ documents.length,
523
+ `Uploaded ${result.restored} attachments`
524
+ );
525
+ return result;
526
+ }
527
+
528
+ /**
529
+ * Upload a single attachment file
530
+ */
531
+ private async uploadSingleAttachment(
532
+ staticPath: string,
533
+ docId: string,
534
+ attachmentName: string,
535
+ attachmentMeta: any,
536
+ db: PouchDB.Database
537
+ ): Promise<AttachmentUploadResult> {
538
+ const result: AttachmentUploadResult = {
539
+ success: false,
540
+ attachmentName,
541
+ docId,
542
+ };
543
+
544
+ try {
545
+ // Get the file path from the attachment metadata
546
+ if (!attachmentMeta.path) {
547
+ result.error = 'Attachment metadata missing file path';
548
+ return result;
549
+ }
550
+
551
+ // Load the attachment data
552
+ let attachmentData: ArrayBuffer | Buffer;
553
+ let attachmentPath: string;
554
+
555
+ if (this.fs) {
556
+ // Use injected file system adapter (preferred)
557
+ attachmentPath = this.fs.joinPath(staticPath, attachmentMeta.path);
558
+ attachmentData = await this.fs.readBinary(attachmentPath);
559
+ } else {
560
+ // Fallback to legacy behavior for backward compatibility
561
+ attachmentPath =
562
+ nodeFS && nodePath
563
+ ? nodePath.join(staticPath, attachmentMeta.path)
564
+ : `${staticPath}/${attachmentMeta.path}`;
565
+
566
+ if (nodeFS && this.isLocalPath(staticPath)) {
567
+ // Node.js file system access
568
+ attachmentData = await nodeFS.promises.readFile(attachmentPath);
569
+ } else {
570
+ // Browser/fetch access
571
+ const response = await fetch(attachmentPath);
572
+ if (!response.ok) {
573
+ result.error = `Failed to fetch attachment: ${response.status} ${response.statusText}`;
574
+ return result;
575
+ }
576
+ attachmentData = await response.arrayBuffer();
577
+ }
578
+ }
579
+
580
+ // Get current document revision (needed for putAttachment)
581
+ const doc = await db.get(docId);
582
+
583
+ // Upload to CouchDB
584
+ await db.putAttachment(
585
+ docId,
586
+ attachmentName,
587
+ doc._rev,
588
+ attachmentData as any, // PouchDB accepts both ArrayBuffer and Buffer
589
+ attachmentMeta.content_type
590
+ );
591
+
592
+ result.success = true;
593
+ } catch (error) {
594
+ result.error = error instanceof Error ? error.message : String(error);
595
+ }
596
+
597
+ return result;
598
+ }
599
+
600
+ /**
601
+ * Restore CourseConfig document from manifest
602
+ */
603
+ private async restoreCourseConfig(
604
+ manifest: StaticCourseManifest,
605
+ targetDB: PouchDB.Database
606
+ ): Promise<RestoreResults> {
607
+ const results: RestoreResults = {
608
+ restored: 0,
609
+ errors: [],
610
+ warnings: [],
611
+ };
612
+
613
+ try {
614
+ // Validate courseConfig exists
615
+ if (!manifest.courseConfig) {
616
+ results.warnings.push(
617
+ 'No courseConfig found in manifest, skipping CourseConfig document creation'
618
+ );
619
+ return results;
620
+ }
621
+
622
+ // Create CourseConfig document
623
+ const courseConfigDoc: { [key: string]: any; _id: string; _rev?: string } = {
624
+ _id: 'CourseConfig',
625
+ ...manifest.courseConfig,
626
+ courseID: manifest.courseId,
627
+ };
628
+ delete courseConfigDoc._rev;
629
+
630
+ // Upload to CouchDB
631
+ await targetDB.put(courseConfigDoc);
632
+ results.restored = 1;
633
+
634
+ logger.info(`CourseConfig document created for course: ${manifest.courseId}`);
635
+ } catch (error) {
636
+ const errorMessage = error instanceof Error ? error.message : JSON.stringify(error);
637
+ results.errors.push(`Failed to restore CourseConfig: ${errorMessage}`);
638
+ logger.error('CourseConfig restoration failed:', error);
639
+ }
640
+
641
+ return results;
642
+ }
643
+
644
+ /**
645
+ * Calculate expected document counts from manifest
646
+ */
647
+ private calculateExpectedCounts(manifest: StaticCourseManifest): DocumentCounts {
648
+ const counts: DocumentCounts = {};
649
+
650
+ // Count documents by type from chunks
651
+ for (const chunk of manifest.chunks) {
652
+ counts[chunk.docType] = (counts[chunk.docType] || 0) + chunk.documentCount;
653
+ }
654
+
655
+ // Count design documents
656
+ if (manifest.designDocs.length > 0) {
657
+ counts['_design'] = manifest.designDocs.length;
658
+ }
659
+
660
+ return counts;
661
+ }
662
+
663
+ /**
664
+ * Clean up database after failed migration
665
+ */
666
+ private async cleanupFailedMigration(db: PouchDB.Database): Promise<void> {
667
+ logger.info('Cleaning up failed migration...');
668
+
669
+ try {
670
+ // Get all documents and delete them
671
+ const allDocs = await db.allDocs();
672
+ const docsToDelete = allDocs.rows.map((row) => ({
673
+ _id: row.id,
674
+ _rev: row.value.rev,
675
+ _deleted: true,
676
+ }));
677
+
678
+ if (docsToDelete.length > 0) {
679
+ await db.bulkDocs(docsToDelete);
680
+ logger.info(`Cleaned up ${docsToDelete.length} documents from failed migration`);
681
+ }
682
+ } catch (error) {
683
+ logger.error('Failed to cleanup documents:', error);
684
+ throw error;
685
+ }
686
+ }
687
+
688
+ /**
689
+ * Report progress to callback if available
690
+ */
691
+ private reportProgress(
692
+ phase: RestoreProgress['phase'],
693
+ current: number,
694
+ total: number,
695
+ message: string
696
+ ): void {
697
+ if (this.progressCallback) {
698
+ this.progressCallback({
699
+ phase,
700
+ current,
701
+ total,
702
+ message,
703
+ });
704
+ }
705
+ }
706
+
707
+ /**
708
+ * Check if a path is a local file path (vs URL)
709
+ */
710
+ private isLocalPath(path: string): boolean {
711
+ return !path.startsWith('http://') && !path.startsWith('https://');
712
+ }
713
+ }