@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.
- package/CLAUDE.md +43 -0
- package/dist/core/index.d.mts +5 -5
- package/dist/core/index.d.ts +5 -5
- package/dist/core/index.js +2130 -7527
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +2136 -7554
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-B8wquRiB.d.mts → dataLayerProvider-6stCgDME.d.ts} +5 -1
- package/dist/{dataLayerProvider-DRjMZMaf.d.ts → dataLayerProvider-BbW9EnZK.d.mts} +5 -1
- package/dist/impl/couch/index.d.mts +6 -6
- package/dist/impl/couch/index.d.ts +6 -6
- package/dist/impl/couch/index.js +2040 -7420
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +2046 -7447
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.mts +38 -54
- package/dist/impl/static/index.d.ts +38 -54
- package/dist/impl/static/index.js +812 -6206
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +799 -6213
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.mts +244 -8
- package/dist/index.d.ts +244 -8
- package/dist/index.js +4885 -8853
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +4485 -8487
- package/dist/index.mjs.map +1 -1
- package/dist/{types-B0GJsjOr.d.ts → types-BvzcRAys.d.ts} +14 -3
- package/dist/{types-DIgj8pP7.d.mts → types-CQQ80R5N.d.mts} +14 -3
- package/dist/{types-legacy-CTsJvvxI.d.mts → types-legacy-CtrmkOLu.d.mts} +3 -1
- package/dist/{types-legacy-CTsJvvxI.d.ts → types-legacy-CtrmkOLu.d.ts} +3 -1
- package/dist/{userDB-ZSwOXiYN.d.mts → userDB-7fM4tpgr.d.mts} +10 -3
- package/dist/{userDB-C5dcuRZs.d.ts → userDB-DUY63VMN.d.ts} +10 -3
- package/dist/util/packer/index.d.mts +43 -3
- package/dist/util/packer/index.d.ts +43 -3
- package/dist/util/packer/index.js +241 -36
- package/dist/util/packer/index.js.map +1 -1
- package/dist/util/packer/index.mjs +241 -36
- package/dist/util/packer/index.mjs.map +1 -1
- package/package.json +2 -2
- package/src/core/interfaces/courseDB.ts +1 -1
- package/src/core/interfaces/dataLayerProvider.ts +5 -0
- package/src/core/interfaces/userDB.ts +5 -0
- package/src/core/types/types-legacy.ts +2 -0
- package/src/factory.ts +25 -0
- package/src/impl/common/BaseUserDB.ts +87 -6
- package/src/impl/common/userDBHelpers.ts +11 -1
- package/src/impl/couch/PouchDataLayerProvider.ts +4 -0
- package/src/impl/couch/courseAPI.ts +31 -16
- package/src/impl/couch/courseDB.ts +8 -7
- package/src/impl/couch/courseLookupDB.ts +24 -0
- package/src/impl/static/StaticDataLayerProvider.ts +4 -0
- package/src/impl/static/StaticDataUnpacker.ts +175 -2
- package/src/impl/static/courseDB.ts +18 -0
- package/src/impl/static/index.ts +0 -1
- package/src/util/dataDirectory.test.ts +53 -0
- package/src/util/dataDirectory.ts +52 -0
- package/src/util/index.ts +3 -0
- package/src/util/migrator/FileSystemAdapter.ts +59 -0
- package/src/util/migrator/StaticToCouchDBMigrator.ts +707 -0
- package/src/util/migrator/index.ts +18 -0
- package/src/util/migrator/types.ts +84 -0
- package/src/util/migrator/validation.ts +517 -0
- package/src/util/packer/CouchDBToStaticPacker.ts +320 -49
- package/src/util/packer/types.ts +13 -1
- package/src/util/tuiLogger.ts +139 -0
- 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
|
+
}
|