@vue-skuilder/db 0.1.4 → 0.1.6
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/SyncStrategy-DnJRj-Xp.d.mts +74 -0
- package/dist/SyncStrategy-DnJRj-Xp.d.ts +74 -0
- package/dist/core/index.d.mts +90 -2
- package/dist/core/index.d.ts +90 -2
- package/dist/core/index.js +856 -6155
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +778 -6097
- package/dist/core/index.mjs.map +1 -1
- package/dist/dataLayerProvider-BZmLyBVw.d.mts +41 -0
- package/dist/dataLayerProvider-BuntXkCs.d.ts +41 -0
- package/dist/impl/couch/index.d.mts +292 -0
- package/dist/impl/couch/index.d.ts +292 -0
- package/dist/impl/couch/index.js +3075 -0
- package/dist/impl/couch/index.js.map +1 -0
- package/dist/impl/couch/index.mjs +3007 -0
- package/dist/impl/couch/index.mjs.map +1 -0
- package/dist/impl/static/index.d.mts +188 -0
- package/dist/impl/static/index.d.ts +188 -0
- package/dist/impl/static/index.js +3055 -0
- package/dist/impl/static/index.js.map +1 -0
- package/dist/impl/static/index.mjs +3025 -0
- package/dist/impl/static/index.mjs.map +1 -0
- package/dist/index.d.mts +13 -4
- package/dist/index.d.ts +13 -4
- package/dist/index.js +2920 -6846
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +3567 -7513
- package/dist/index.mjs.map +1 -1
- package/dist/types-D6SnlHPm.d.ts +58 -0
- package/dist/types-DPRvCrIk.d.mts +58 -0
- package/dist/types-legacy-WPe8CtO-.d.mts +139 -0
- package/dist/types-legacy-WPe8CtO-.d.ts +139 -0
- package/dist/{index-QMtzQI65.d.mts → userDB-31gsvxyd.d.mts} +11 -252
- package/dist/{index-QMtzQI65.d.ts → userDB-D9EuWTp1.d.ts} +11 -252
- package/dist/util/packer/index.d.mts +65 -0
- package/dist/util/packer/index.d.ts +65 -0
- package/dist/util/packer/index.js +512 -0
- package/dist/util/packer/index.js.map +1 -0
- package/dist/util/packer/index.mjs +485 -0
- package/dist/util/packer/index.mjs.map +1 -0
- package/package.json +12 -2
- package/src/core/interfaces/contentSource.ts +8 -6
- package/src/core/interfaces/courseDB.ts +1 -1
- package/src/core/interfaces/dataLayerProvider.ts +5 -0
- package/src/core/interfaces/userDB.ts +7 -2
- package/src/core/types/types-legacy.ts +2 -0
- package/src/factory.ts +10 -7
- package/src/impl/{pouch/userDB.ts → common/BaseUserDB.ts} +283 -260
- package/src/impl/common/SyncStrategy.ts +90 -0
- package/src/impl/common/index.ts +23 -0
- package/src/impl/common/types.ts +50 -0
- package/src/impl/common/userDBHelpers.ts +144 -0
- package/src/impl/couch/CouchDBSyncStrategy.ts +209 -0
- package/src/impl/{pouch → couch}/PouchDataLayerProvider.ts +16 -7
- package/src/impl/{pouch → couch}/adminDB.ts +3 -3
- package/src/impl/{pouch → couch}/auth.ts +2 -2
- package/src/impl/{pouch → couch}/classroomDB.ts +6 -6
- package/src/impl/{pouch → couch}/courseAPI.ts +59 -21
- package/src/impl/{pouch → couch}/courseDB.ts +32 -17
- package/src/impl/{pouch → couch}/courseLookupDB.ts +1 -1
- package/src/impl/{pouch → couch}/index.ts +27 -20
- package/src/impl/{pouch → couch}/updateQueue.ts +5 -1
- package/src/impl/{pouch → couch}/user-course-relDB.ts +6 -1
- package/src/impl/static/NoOpSyncStrategy.ts +70 -0
- package/src/impl/static/StaticDataLayerProvider.ts +93 -0
- package/src/impl/static/StaticDataUnpacker.ts +549 -0
- package/src/impl/static/courseDB.ts +275 -0
- package/src/impl/static/coursesDB.ts +37 -0
- package/src/impl/static/index.ts +7 -0
- package/src/index.ts +1 -1
- package/src/study/SessionController.ts +4 -4
- package/src/study/SpacedRepetition.ts +3 -3
- package/src/study/getCardDataShape.ts +2 -2
- package/src/util/index.ts +1 -0
- package/src/util/packer/CouchDBToStaticPacker.ts +620 -0
- package/src/util/packer/index.ts +4 -0
- package/src/util/packer/types.ts +64 -0
- package/tsconfig.json +7 -10
- package/tsup.config.ts +5 -3
- /package/src/impl/{pouch → couch}/clientCache.ts +0 -0
- /package/src/impl/{pouch → couch}/pouchdb-setup.ts +0 -0
- /package/src/impl/{pouch → couch}/types.ts +0 -0
|
@@ -0,0 +1,549 @@
|
|
|
1
|
+
// packages/db/src/impl/static/StaticDataUnpacker.ts
|
|
2
|
+
|
|
3
|
+
import { StaticCourseManifest, ChunkMetadata } from '../../util/packer/types';
|
|
4
|
+
import { logger } from '../../util/logger';
|
|
5
|
+
import { DocType } from '@db/core';
|
|
6
|
+
|
|
7
|
+
// Browser-compatible path utilities
|
|
8
|
+
const pathUtils = {
|
|
9
|
+
isAbsolute: (path: string): boolean => {
|
|
10
|
+
// Check for Windows absolute paths (C:\ or \\server\)
|
|
11
|
+
if (/^[a-zA-Z]:[\\/]/.test(path) || /^\\\\/.test(path)) {
|
|
12
|
+
return true;
|
|
13
|
+
}
|
|
14
|
+
// Check for Unix absolute paths (/)
|
|
15
|
+
if (path.startsWith('/')) {
|
|
16
|
+
return true;
|
|
17
|
+
}
|
|
18
|
+
return false;
|
|
19
|
+
},
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
// Check if we're in Node.js environment and fs is available
|
|
23
|
+
let nodeFS: any = null;
|
|
24
|
+
try {
|
|
25
|
+
// Use eval to prevent bundlers from including fs in browser builds
|
|
26
|
+
if (typeof window === 'undefined' && typeof process !== 'undefined' && process.versions?.node) {
|
|
27
|
+
nodeFS = eval('require')('fs');
|
|
28
|
+
}
|
|
29
|
+
} catch {
|
|
30
|
+
// fs not available, will use fetch
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
interface EloIndexEntry {
|
|
34
|
+
elo: number;
|
|
35
|
+
cardId: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface EloIndex {
|
|
39
|
+
sorted: EloIndexEntry[];
|
|
40
|
+
buckets: Record<string, EloIndexEntry[]>;
|
|
41
|
+
stats: {
|
|
42
|
+
min: number;
|
|
43
|
+
max: number;
|
|
44
|
+
count: number;
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface TagsIndex {
|
|
49
|
+
byTag: Record<string, string[]>; // tagName -> cardIds
|
|
50
|
+
byCard: Record<string, string[]>; // cardId -> tagNames
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class StaticDataUnpacker {
|
|
54
|
+
private manifest: StaticCourseManifest;
|
|
55
|
+
private basePath: string;
|
|
56
|
+
private documentCache: Map<string, any> = new Map();
|
|
57
|
+
private chunkCache: Map<string, any[]> = new Map();
|
|
58
|
+
private indexCache: Map<string, any> = new Map();
|
|
59
|
+
|
|
60
|
+
constructor(manifest: StaticCourseManifest, basePath: string) {
|
|
61
|
+
this.manifest = manifest;
|
|
62
|
+
this.basePath = basePath;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Get a document by ID, loading from appropriate chunk if needed
|
|
67
|
+
*/
|
|
68
|
+
async getDocument<T = any>(id: string): Promise<T> {
|
|
69
|
+
// Check document cache first
|
|
70
|
+
if (this.documentCache.has(id)) {
|
|
71
|
+
const doc = this.documentCache.get(id);
|
|
72
|
+
return await this.hydrateAttachments(doc);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Find which chunk contains this document
|
|
76
|
+
const chunk = await this.findChunkForDocument(id);
|
|
77
|
+
if (!chunk) {
|
|
78
|
+
logger.error(
|
|
79
|
+
`Document ${id} not found in any chunk. Available chunks:`,
|
|
80
|
+
this.manifest.chunks.map((c) => `${c.id} (${c.docType}): ${c.startKey} - ${c.endKey}`)
|
|
81
|
+
);
|
|
82
|
+
throw new Error(`Document ${id} not found in any chunk`);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Load the chunk if not cached
|
|
86
|
+
await this.loadChunk(chunk.id);
|
|
87
|
+
|
|
88
|
+
// Try to get the document from cache again
|
|
89
|
+
if (this.documentCache.has(id)) {
|
|
90
|
+
const doc = this.documentCache.get(id);
|
|
91
|
+
return await this.hydrateAttachments(doc);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
logger.error(`Document ${id} not found in chunk ${chunk.id}`);
|
|
95
|
+
throw new Error(`Document ${id} not found in chunk ${chunk.id}`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Query cards by ELO score, returning card IDs sorted by ELO
|
|
100
|
+
*/
|
|
101
|
+
async queryByElo(targetElo: number, limit: number = 25): Promise<string[]> {
|
|
102
|
+
const eloIndex = (await this.loadIndex('elo')) as EloIndex;
|
|
103
|
+
|
|
104
|
+
if (!eloIndex || !eloIndex.sorted) {
|
|
105
|
+
logger.warn('ELO index not found or malformed, returning empty results');
|
|
106
|
+
return [];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Find cards near the target ELO
|
|
110
|
+
const sorted = eloIndex.sorted;
|
|
111
|
+
let startIndex = 0;
|
|
112
|
+
|
|
113
|
+
// Binary search to find insertion point for target ELO
|
|
114
|
+
let left = 0;
|
|
115
|
+
let right = sorted.length - 1;
|
|
116
|
+
while (left <= right) {
|
|
117
|
+
const mid = Math.floor((left + right) / 2);
|
|
118
|
+
if (sorted[mid].elo < targetElo) {
|
|
119
|
+
left = mid + 1;
|
|
120
|
+
} else {
|
|
121
|
+
right = mid - 1;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
startIndex = left;
|
|
125
|
+
|
|
126
|
+
// Collect cards around the target ELO
|
|
127
|
+
const result: string[] = [];
|
|
128
|
+
const halfLimit = Math.floor(limit / 2);
|
|
129
|
+
|
|
130
|
+
// Get cards below target ELO
|
|
131
|
+
for (
|
|
132
|
+
let i = Math.max(0, startIndex - halfLimit);
|
|
133
|
+
i < startIndex && result.length < limit;
|
|
134
|
+
i++
|
|
135
|
+
) {
|
|
136
|
+
result.push(sorted[i].cardId);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Get cards at or above target ELO
|
|
140
|
+
for (let i = startIndex; i < sorted.length && result.length < limit; i++) {
|
|
141
|
+
result.push(sorted[i].cardId);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
return result;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Get all tag names mapped to their card arrays
|
|
149
|
+
*/
|
|
150
|
+
async getTagsIndex(): Promise<TagsIndex> {
|
|
151
|
+
return (await this.loadIndex('tags')) as TagsIndex;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Find which chunk contains a specific document ID
|
|
156
|
+
*/
|
|
157
|
+
private async findChunkForDocument(docId: string): Promise<ChunkMetadata | undefined> {
|
|
158
|
+
// Determine document type from ID pattern by checking all DocType enum members
|
|
159
|
+
let expectedDocType: DocType | undefined = undefined;
|
|
160
|
+
|
|
161
|
+
// Check for ID prefixes matching any DocType enum value
|
|
162
|
+
for (const docType of Object.values(DocType)) {
|
|
163
|
+
if (docId.startsWith(`${docType}-`)) {
|
|
164
|
+
expectedDocType = docType;
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
if (expectedDocType !== undefined) {
|
|
170
|
+
// Use chunk filtering by docType for documents with recognized prefixes
|
|
171
|
+
const typeChunks = this.manifest.chunks.filter((c) => c.docType === expectedDocType);
|
|
172
|
+
|
|
173
|
+
for (const chunk of typeChunks) {
|
|
174
|
+
if (docId >= chunk.startKey && docId <= chunk.endKey) {
|
|
175
|
+
// Verify document actually exists in chunk
|
|
176
|
+
const exists = await this.verifyDocumentInChunk(docId, chunk);
|
|
177
|
+
if (exists) {
|
|
178
|
+
return chunk;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return undefined;
|
|
184
|
+
} else {
|
|
185
|
+
// Fall back to trying all chunk types with strict verification
|
|
186
|
+
// Since card IDs and displayable data IDs can overlap in range, we need to verify actual existence
|
|
187
|
+
|
|
188
|
+
// First try DISPLAYABLE_DATA chunks (most likely for documents without prefixes)
|
|
189
|
+
const displayableChunks = this.manifest.chunks.filter(
|
|
190
|
+
(c) => c.docType === 'DISPLAYABLE_DATA'
|
|
191
|
+
);
|
|
192
|
+
for (const chunk of displayableChunks) {
|
|
193
|
+
if (docId >= chunk.startKey && docId <= chunk.endKey) {
|
|
194
|
+
// Verify document actually exists in chunk
|
|
195
|
+
const exists = await this.verifyDocumentInChunk(docId, chunk);
|
|
196
|
+
if (exists) {
|
|
197
|
+
return chunk;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Then try CARD chunks (for legacy card IDs without prefixes)
|
|
203
|
+
const cardChunks = this.manifest.chunks.filter((c) => c.docType === 'CARD');
|
|
204
|
+
for (const chunk of cardChunks) {
|
|
205
|
+
if (docId >= chunk.startKey && docId <= chunk.endKey) {
|
|
206
|
+
// Verify document actually exists in chunk
|
|
207
|
+
const exists = await this.verifyDocumentInChunk(docId, chunk);
|
|
208
|
+
if (exists) {
|
|
209
|
+
return chunk;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Finally try any other chunk types
|
|
215
|
+
const otherChunks = this.manifest.chunks.filter(
|
|
216
|
+
(c) => c.docType !== 'CARD' && c.docType !== 'DISPLAYABLE_DATA' && c.docType !== 'TAG'
|
|
217
|
+
);
|
|
218
|
+
for (const chunk of otherChunks) {
|
|
219
|
+
if (docId >= chunk.startKey && docId <= chunk.endKey) {
|
|
220
|
+
// Verify document actually exists in chunk
|
|
221
|
+
const exists = await this.verifyDocumentInChunk(docId, chunk);
|
|
222
|
+
if (exists) {
|
|
223
|
+
return chunk;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
return undefined;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Verify that a document actually exists in a specific chunk by loading and checking it
|
|
234
|
+
*/
|
|
235
|
+
private async verifyDocumentInChunk(docId: string, chunk: ChunkMetadata): Promise<boolean> {
|
|
236
|
+
try {
|
|
237
|
+
// Load the chunk if not already cached
|
|
238
|
+
await this.loadChunk(chunk.id);
|
|
239
|
+
|
|
240
|
+
// Check if the document is now in our document cache
|
|
241
|
+
return this.documentCache.has(docId);
|
|
242
|
+
} catch {
|
|
243
|
+
return false;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Load a chunk file and cache its documents
|
|
249
|
+
*/
|
|
250
|
+
private async loadChunk(chunkId: string): Promise<void> {
|
|
251
|
+
if (this.chunkCache.has(chunkId)) {
|
|
252
|
+
return; // Already loaded
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const chunk = this.manifest.chunks.find((c) => c.id === chunkId);
|
|
256
|
+
if (!chunk) {
|
|
257
|
+
throw new Error(`Chunk ${chunkId} not found in manifest`);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
try {
|
|
261
|
+
const chunkPath = `${this.basePath}/${chunk.path}`;
|
|
262
|
+
logger.debug(`Loading chunk from ${chunkPath}`);
|
|
263
|
+
|
|
264
|
+
let documents: any[];
|
|
265
|
+
|
|
266
|
+
// Check if we're in a Node.js environment with local files
|
|
267
|
+
if (this.isLocalPath(chunkPath) && nodeFS) {
|
|
268
|
+
// Use fs for local file access (e.g., in tests)
|
|
269
|
+
const fileContent = await nodeFS.promises.readFile(chunkPath, 'utf8');
|
|
270
|
+
documents = JSON.parse(fileContent);
|
|
271
|
+
} else {
|
|
272
|
+
// Use fetch for URL-based access (e.g., in browser)
|
|
273
|
+
const response = await fetch(chunkPath);
|
|
274
|
+
if (!response.ok) {
|
|
275
|
+
throw new Error(
|
|
276
|
+
`Failed to fetch chunk ${chunkId}: ${response.status} ${response.statusText}`
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
documents = await response.json();
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this.chunkCache.set(chunkId, documents);
|
|
283
|
+
|
|
284
|
+
// Cache individual documents for quick lookup
|
|
285
|
+
for (const doc of documents) {
|
|
286
|
+
if (doc._id) {
|
|
287
|
+
this.documentCache.set(doc._id, doc);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
logger.debug(`Loaded ${documents.length} documents from chunk ${chunkId}`);
|
|
292
|
+
} catch (error) {
|
|
293
|
+
logger.error(`Failed to load chunk ${chunkId}:`, error);
|
|
294
|
+
throw error;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Load an index file and cache it
|
|
300
|
+
*/
|
|
301
|
+
private async loadIndex(indexName: string): Promise<any> {
|
|
302
|
+
if (this.indexCache.has(indexName)) {
|
|
303
|
+
return this.indexCache.get(indexName);
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const indexMeta = this.manifest.indices.find((idx) => idx.name === indexName);
|
|
307
|
+
if (!indexMeta) {
|
|
308
|
+
throw new Error(`Index ${indexName} not found in manifest`);
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const indexPath = `${this.basePath}/${indexMeta.path}`;
|
|
313
|
+
logger.debug(`Loading index from ${indexPath}`);
|
|
314
|
+
|
|
315
|
+
let indexData: any;
|
|
316
|
+
|
|
317
|
+
// Check if we're in a Node.js environment with local files
|
|
318
|
+
if (this.isLocalPath(indexPath) && nodeFS) {
|
|
319
|
+
// Use fs for local file access (e.g., in tests)
|
|
320
|
+
const fileContent = await nodeFS.promises.readFile(indexPath, 'utf8');
|
|
321
|
+
indexData = JSON.parse(fileContent);
|
|
322
|
+
} else {
|
|
323
|
+
// Use fetch for URL-based access (e.g., in browser)
|
|
324
|
+
const response = await fetch(indexPath);
|
|
325
|
+
if (!response.ok) {
|
|
326
|
+
throw new Error(
|
|
327
|
+
`Failed to fetch index ${indexName}: ${response.status} ${response.statusText}`
|
|
328
|
+
);
|
|
329
|
+
}
|
|
330
|
+
indexData = await response.json();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
this.indexCache.set(indexName, indexData);
|
|
334
|
+
|
|
335
|
+
logger.debug(`Loaded index ${indexName}`);
|
|
336
|
+
return indexData;
|
|
337
|
+
} catch (error) {
|
|
338
|
+
logger.error(`Failed to load index ${indexName}:`, error);
|
|
339
|
+
throw error;
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* Get a document by ID without hydration (raw document access)
|
|
345
|
+
* Used internally to avoid recursion during attachment hydration
|
|
346
|
+
*/
|
|
347
|
+
private async getRawDocument<T = any>(id: string): Promise<T> {
|
|
348
|
+
// Check document cache first
|
|
349
|
+
if (this.documentCache.has(id)) {
|
|
350
|
+
return this.documentCache.get(id);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
// Find which chunk contains this document
|
|
354
|
+
const chunk = await this.findChunkForDocument(id);
|
|
355
|
+
if (!chunk) {
|
|
356
|
+
logger.error(
|
|
357
|
+
`Document ${id} not found in any chunk. Available chunks:`,
|
|
358
|
+
this.manifest.chunks.map((c) => `${c.id} (${c.docType}): ${c.startKey} - ${c.endKey}`)
|
|
359
|
+
);
|
|
360
|
+
throw new Error(`Document ${id} not found in any chunk`);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Load the chunk if not cached
|
|
364
|
+
await this.loadChunk(chunk.id);
|
|
365
|
+
|
|
366
|
+
// Try to get the document from cache again
|
|
367
|
+
if (this.documentCache.has(id)) {
|
|
368
|
+
return this.documentCache.get(id);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
logger.error(`Document ${id} not found in chunk ${chunk.id}`);
|
|
372
|
+
throw new Error(`Document ${id} not found in chunk ${chunk.id}`);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Get attachment URL for a given document and attachment name
|
|
377
|
+
*/
|
|
378
|
+
getAttachmentUrl(docId: string, attachmentName: string): string {
|
|
379
|
+
// Construct attachment path: attachments/{docId}/{attachmentName}.{ext}
|
|
380
|
+
// The exact filename will be resolved from the document's _attachments metadata
|
|
381
|
+
return `${this.basePath}/attachments/${docId}/${attachmentName}`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Load attachment data from the document and get the correct file path
|
|
386
|
+
* Uses raw document access to avoid recursion with hydration
|
|
387
|
+
*/
|
|
388
|
+
async getAttachmentPath(docId: string, attachmentName: string): Promise<string | null> {
|
|
389
|
+
try {
|
|
390
|
+
const doc = await this.getRawDocument(docId);
|
|
391
|
+
if (doc._attachments && doc._attachments[attachmentName]) {
|
|
392
|
+
const attachment = doc._attachments[attachmentName];
|
|
393
|
+
if (attachment.path) {
|
|
394
|
+
// Return the full path as stored in the document
|
|
395
|
+
return `${this.basePath}/${attachment.path}`;
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
return null;
|
|
399
|
+
} catch {
|
|
400
|
+
return null;
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/**
|
|
405
|
+
* Load attachment as a blob (for browser) or buffer (for Node.js)
|
|
406
|
+
*/
|
|
407
|
+
async getAttachmentBlob(docId: string, attachmentName: string): Promise<Blob | Buffer | null> {
|
|
408
|
+
const attachmentPath = await this.getAttachmentPath(docId, attachmentName);
|
|
409
|
+
if (!attachmentPath) {
|
|
410
|
+
return null;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
try {
|
|
414
|
+
// Check if we're in a Node.js environment with local files
|
|
415
|
+
if (this.isLocalPath(attachmentPath) && nodeFS) {
|
|
416
|
+
// Use fs for local file access (e.g., in tests)
|
|
417
|
+
const buffer = await nodeFS.promises.readFile(attachmentPath);
|
|
418
|
+
return buffer;
|
|
419
|
+
} else {
|
|
420
|
+
// Use fetch for URL-based access (e.g., in browser)
|
|
421
|
+
const response = await fetch(attachmentPath);
|
|
422
|
+
if (!response.ok) {
|
|
423
|
+
throw new Error(
|
|
424
|
+
`Failed to fetch attachment ${docId}/${attachmentName}: ${response.status} ${response.statusText}`
|
|
425
|
+
);
|
|
426
|
+
}
|
|
427
|
+
return await response.blob();
|
|
428
|
+
}
|
|
429
|
+
} catch (error) {
|
|
430
|
+
logger.error(`Failed to load attachment ${docId}/${attachmentName}:`, error);
|
|
431
|
+
return null;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Hydrate document attachments by converting file paths to blob URLs
|
|
437
|
+
*/
|
|
438
|
+
private async hydrateAttachments<T = any>(doc: T): Promise<T> {
|
|
439
|
+
// logger.debug(`[hydrateAttachments] Starting hydration for doc: ${JSON.stringify(doc)}`);
|
|
440
|
+
const typedDoc = doc as any;
|
|
441
|
+
|
|
442
|
+
// If no attachments, return document as-is
|
|
443
|
+
if (!typedDoc._attachments) {
|
|
444
|
+
return doc;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
// Clone the document to avoid mutating the cached version
|
|
448
|
+
const hydratedDoc = JSON.parse(JSON.stringify(doc));
|
|
449
|
+
|
|
450
|
+
// Process each attachment
|
|
451
|
+
for (const [attachmentName, attachment] of Object.entries(typedDoc._attachments)) {
|
|
452
|
+
// logger.debug(
|
|
453
|
+
// `[hydrateAttachments] Processing attachment: ${attachmentName} for doc ${typedDoc?._id}`
|
|
454
|
+
// );
|
|
455
|
+
const attachmentData = attachment as any;
|
|
456
|
+
|
|
457
|
+
// If attachment has a path, convert it to a blob URL
|
|
458
|
+
if (attachmentData.path) {
|
|
459
|
+
// logger.debug(
|
|
460
|
+
// `[hydrateAttachments] Attachment ${attachmentName} has path: ${attachmentData.path}. Attempting to get blob.`
|
|
461
|
+
// );
|
|
462
|
+
try {
|
|
463
|
+
const blob = await this.getAttachmentBlob(typedDoc._id, attachmentName);
|
|
464
|
+
if (blob) {
|
|
465
|
+
// logger.debug(
|
|
466
|
+
// `[hydrateAttachments] Successfully retrieved blob for ${typedDoc._id}/${attachmentName}. Size: ${blob instanceof Blob ? blob.size : (blob as Buffer).length}`
|
|
467
|
+
// );
|
|
468
|
+
// Create blob URL for browser rendering
|
|
469
|
+
if (typeof window !== 'undefined' && window.URL) {
|
|
470
|
+
// Store attachment data in PouchDB-compatible format
|
|
471
|
+
hydratedDoc._attachments[attachmentName] = {
|
|
472
|
+
...attachmentData,
|
|
473
|
+
data: blob,
|
|
474
|
+
stub: false, // Indicates this contains actual data, not just metadata
|
|
475
|
+
};
|
|
476
|
+
// logger.debug(
|
|
477
|
+
// `[hydrateAttachments] Added blobUrl and blob to attachment ${attachmentName} for doc ${typedDoc._id}.`
|
|
478
|
+
// );
|
|
479
|
+
} else {
|
|
480
|
+
// In Node.js environment, just attach the buffer
|
|
481
|
+
hydratedDoc._attachments[attachmentName] = {
|
|
482
|
+
...attachmentData,
|
|
483
|
+
buffer: blob, // Attach buffer for Node.js use
|
|
484
|
+
};
|
|
485
|
+
// logger.debug(
|
|
486
|
+
// `[hydrateAttachments] Added buffer to attachment ${attachmentName} for doc ${typedDoc._id}.`
|
|
487
|
+
// );
|
|
488
|
+
}
|
|
489
|
+
} else {
|
|
490
|
+
logger.warn(
|
|
491
|
+
`[hydrateAttachments] getAttachmentBlob returned null for ${typedDoc._id}/${attachmentName}. Skipping hydration for this attachment.`
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
} catch (error) {
|
|
495
|
+
logger.warn(
|
|
496
|
+
`[hydrateAttachments] Failed to hydrate attachment ${typedDoc._id}/${attachmentName}:`,
|
|
497
|
+
error
|
|
498
|
+
);
|
|
499
|
+
// Keep original attachment data if hydration fails
|
|
500
|
+
}
|
|
501
|
+
} else {
|
|
502
|
+
logger.debug(
|
|
503
|
+
`[hydrateAttachments] Attachment ${attachmentName} for doc ${typedDoc._id} has no path. Skipping blob conversion.`
|
|
504
|
+
);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// logger.debug(
|
|
509
|
+
// `[hydrateAttachments] Finished hydration for doc ${typedDoc?._id}. Returning hydrated doc: ${JSON.stringify(hydratedDoc)}`
|
|
510
|
+
// );
|
|
511
|
+
return hydratedDoc;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* Clear all caches (useful for testing or memory management)
|
|
516
|
+
*/
|
|
517
|
+
clearCaches(): void {
|
|
518
|
+
this.documentCache.clear();
|
|
519
|
+
this.chunkCache.clear();
|
|
520
|
+
this.indexCache.clear();
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
/**
|
|
524
|
+
* Get cache statistics for debugging
|
|
525
|
+
*/
|
|
526
|
+
getCacheStats(): {
|
|
527
|
+
documents: number;
|
|
528
|
+
chunks: number;
|
|
529
|
+
indices: number;
|
|
530
|
+
} {
|
|
531
|
+
return {
|
|
532
|
+
documents: this.documentCache.size,
|
|
533
|
+
chunks: this.chunkCache.size,
|
|
534
|
+
indices: this.indexCache.size,
|
|
535
|
+
};
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* Check if a path is a local file path (vs URL)
|
|
540
|
+
*/
|
|
541
|
+
private isLocalPath(filePath: string): boolean {
|
|
542
|
+
// Check if it's an absolute path or doesn't start with http/https
|
|
543
|
+
return (
|
|
544
|
+
!filePath.startsWith('http://') &&
|
|
545
|
+
!filePath.startsWith('https://') &&
|
|
546
|
+
(pathUtils.isAbsolute(filePath) || filePath.startsWith('./') || filePath.startsWith('../'))
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
}
|