@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
@@ -6,10 +6,11 @@ import { NameSpacer, ShapeDescriptor } from '@vue-skuilder/common';
6
6
  import { CourseConfig, DataShape } from '@vue-skuilder/common';
7
7
  import { CourseElo, blankCourseElo, toCourseElo } from '@vue-skuilder/common';
8
8
  import { CourseDB, createTag } from './courseDB';
9
- import { CardData, DisplayableData, DocType, Tag } from '../../core/types/types-legacy';
9
+ import { CardData, DisplayableData, DocType, Tag, DocTypePrefixes } from '../../core/types/types-legacy';
10
10
  import { prepareNote55 } from '@vue-skuilder/common';
11
11
  import { BaseUser } from '../common';
12
12
  import { logger } from '@db/util/logger';
13
+ import { v4 as uuidv4 } from 'uuid';
13
14
 
14
15
  /**
15
16
  *
@@ -33,9 +34,8 @@ export async function addNote55(
33
34
  ): Promise<PouchDB.Core.Response> {
34
35
  const db = getCourseDB(courseID);
35
36
  const payload = prepareNote55(courseID, codeCourse, shape, data, author, tags, uploads);
36
- // [ ] NAMESPACING: consider put( _id: "displayable_data-uuid")
37
- // consider also semantic hashing
38
- const result = await db.post<DisplayableData>(payload);
37
+ const _id = `${DocTypePrefixes[DocType.DISPLAYABLE_DATA]}-${uuidv4()}`;
38
+ const result = await db.put<DisplayableData>({ ...payload, _id });
39
39
 
40
40
  const dataShapeId = NameSpacer.getDataShapeString({
41
41
  course: codeCourse,
@@ -153,9 +153,10 @@ async function addCard(
153
153
  tags: string[],
154
154
  author: string
155
155
  ): Promise<PouchDB.Core.Response> {
156
- // [ ] NAMESPACING: consider put( _id: "card-uuid")
157
156
  const db = getCourseDB(courseID);
158
- const card = await db.post<CardData>({
157
+ const _id = `${DocTypePrefixes[DocType.CARD]}-${uuidv4()}`;
158
+ const card = await db.put<CardData>({
159
+ _id,
159
160
  course,
160
161
  id_displayable_data,
161
162
  id_view,
@@ -97,6 +97,30 @@ export default class CourseLookup {
97
97
  return resp.id;
98
98
  }
99
99
 
100
+ /**
101
+ * Adds a new course to the lookup database with a specific courseID
102
+ * @param courseId The specific course ID to use
103
+ * @param courseName The course name
104
+ * @param disambiguator Optional disambiguator
105
+ * @returns Promise<void>
106
+ */
107
+ static async addWithId(
108
+ courseId: string,
109
+ courseName: string,
110
+ disambiguator?: string
111
+ ): Promise<void> {
112
+ const doc: Omit<CourseLookupDoc, '_rev'> = {
113
+ _id: courseId,
114
+ name: courseName,
115
+ };
116
+
117
+ if (disambiguator) {
118
+ doc.disambiguator = disambiguator;
119
+ }
120
+
121
+ await CourseLookup._db.put(doc);
122
+ }
123
+
100
124
  /**
101
125
  * Removes a course from the index
102
126
  * @param courseID
@@ -1,5 +1,11 @@
1
1
  import { ENV } from '@db/factory';
2
- import { DocType, GuestUsername, log, SkuilderCourseData } from '../../core/types/types-legacy';
2
+ import {
3
+ DocType,
4
+ DocTypePrefixes,
5
+ GuestUsername,
6
+ log,
7
+ SkuilderCourseData,
8
+ } from '../../core/types/types-legacy';
3
9
  // import { getCurrentUser } from '../../stores/useAuthStore';
4
10
  import moment, { Moment } from 'moment';
5
11
  import { logger } from '@db/util/logger';
@@ -155,7 +161,6 @@ export async function getRandomCards(courseIDs: string[]) {
155
161
  }
156
162
  }
157
163
 
158
- export const REVIEW_PREFIX: string = 'card_review_';
159
164
  export const REVIEW_TIME_FORMAT: string = 'YYYY-MM-DD--kk:mm:ss-SSS';
160
165
 
161
166
  export function getCouchUserDB(username: string): PouchDB.Database {
@@ -191,11 +196,11 @@ export function scheduleCardReview(review: {
191
196
  const now = moment.utc();
192
197
  logger.info(`Scheduling for review in: ${review.time.diff(now, 'h') / 24} days`);
193
198
  void getCouchUserDB(review.user).put<ScheduledCard>({
194
- _id: REVIEW_PREFIX + review.time.format(REVIEW_TIME_FORMAT),
199
+ _id: DocTypePrefixes[DocType.SCHEDULED_CARD] + review.time.format(REVIEW_TIME_FORMAT),
195
200
  cardId: review.card_id,
196
- reviewTime: review.time,
201
+ reviewTime: review.time.toISOString(),
197
202
  courseId: review.course_id,
198
- scheduledAt: now,
203
+ scheduledAt: now.toISOString(),
199
204
  scheduledFor: review.scheduledFor,
200
205
  schedulingAgentId: review.schedulingAgentId,
201
206
  });
@@ -12,7 +12,8 @@ export default class UpdateQueue extends Loggable {
12
12
  [index: string]: boolean;
13
13
  } = {};
14
14
 
15
- private db: PouchDB.Database;
15
+ private readDB: PouchDB.Database; // Database for read operations
16
+ private writeDB: PouchDB.Database; // Database for write operations (local-first)
16
17
 
17
18
  public update<T extends PouchDB.Core.Document<object>>(
18
19
  id: PouchDB.Core.DocumentId,
@@ -27,21 +28,24 @@ export default class UpdateQueue extends Loggable {
27
28
  return this.applyUpdates<T>(id);
28
29
  }
29
30
 
30
- constructor(db: PouchDB.Database) {
31
+ constructor(readDB: PouchDB.Database, writeDB?: PouchDB.Database) {
31
32
  super();
32
33
  // PouchDB.debug.enable('*');
33
- this.db = db;
34
+ this.readDB = readDB;
35
+ this.writeDB = writeDB || readDB; // Default to readDB if writeDB not provided
34
36
  logger.debug(`UpdateQ initialized...`);
35
- void this.db.info().then((i) => {
37
+ void this.readDB.info().then((i) => {
36
38
  logger.debug(`db info: ${JSON.stringify(i)}`);
37
39
  });
38
40
  }
39
41
 
40
- private async applyUpdates<T extends PouchDB.Core.Document<object>>(id: string): Promise<T> {
42
+ private async applyUpdates<T extends PouchDB.Core.Document<object>>(
43
+ id: string
44
+ ): Promise<T & PouchDB.Core.GetMeta & PouchDB.Core.RevisionIdMeta> {
41
45
  logger.debug(`Applying updates on doc: ${id}`);
42
46
  if (this.inprogressUpdates[id]) {
43
47
  // console.log(`Updates in progress...`);
44
- await this.db.info(); // stall for a round trip
48
+ await this.readDB.info(); // stall for a round trip
45
49
  // console.log(`Retrying...`);
46
50
  return this.applyUpdates<T>(id);
47
51
  } else {
@@ -49,7 +53,7 @@ export default class UpdateQueue extends Loggable {
49
53
  this.inprogressUpdates[id] = true;
50
54
 
51
55
  try {
52
- let doc = await this.db.get<T>(id);
56
+ let doc = await this.readDB.get<T>(id);
53
57
  logger.debug(`Retrieved doc: ${id}`);
54
58
  while (this.pendingUpdates[id].length !== 0) {
55
59
  const update = this.pendingUpdates[id].splice(0, 1)[0];
@@ -66,7 +70,7 @@ export default class UpdateQueue extends Loggable {
66
70
  // console.log(`${k}: ${typeof k}`);
67
71
  // }
68
72
  // console.log(`Applied updates to doc: ${JSON.stringify(doc)}`);
69
- await this.db.put<T>(doc);
73
+ await this.writeDB.put<T>(doc);
70
74
  logger.debug(`Put doc: ${id}`);
71
75
 
72
76
  if (this.pendingUpdates[id].length === 0) {
@@ -4,20 +4,18 @@ import {
4
4
  UserCourseSettings,
5
5
  UsrCrsDataInterface,
6
6
  } from '@db/core';
7
+
7
8
  import moment, { Moment } from 'moment';
8
- import { getStartAndEndKeys, REVIEW_PREFIX, REVIEW_TIME_FORMAT } from '.';
9
- import { CourseDB } from './courseDB';
10
- import { User } from './userDB';
9
+
10
+ import { UserDBInterface } from '@db/core';
11
11
  import { logger } from '../../util/logger';
12
12
 
13
13
  export class UsrCrsData implements UsrCrsDataInterface {
14
- private user: User;
15
- private course: CourseDB;
14
+ private user: UserDBInterface;
16
15
  private _courseId: string;
17
16
 
18
- constructor(user: User, courseId: string) {
17
+ constructor(user: UserDBInterface, courseId: string) {
19
18
  this.user = user;
20
- this.course = new CourseDB(courseId, async () => this.user);
21
19
  this._courseId = courseId;
22
20
  }
23
21
 
@@ -47,32 +45,24 @@ export class UsrCrsData implements UsrCrsDataInterface {
47
45
  }
48
46
  }
49
47
  public updateCourseSettings(updates: UserCourseSetting[]): void {
50
- void this.user.updateCourseSettings(this._courseId, updates);
48
+ // TODO: Add updateCourseSettings method to UserDBInterface
49
+ // For now, we'll need to cast to access the concrete implementation
50
+ if ('updateCourseSettings' in this.user) {
51
+ void (this.user as any).updateCourseSettings(this._courseId, updates);
52
+ }
51
53
  }
52
54
 
53
55
  private async getReviewstoDate(targetDate: Moment) {
54
- const keys = getStartAndEndKeys(REVIEW_PREFIX);
55
-
56
- const reviews = await this.user.remote().allDocs<ScheduledCard>({
57
- startkey: keys.startkey,
58
- endkey: keys.endkey,
59
- include_docs: true,
60
- });
56
+ // Use the interface method instead of direct database access
57
+ const allReviews = await this.user.getPendingReviews(this._courseId);
61
58
 
62
59
  logger.debug(
63
60
  `Fetching ${this.user.getUsername()}'s scheduled reviews for course ${this._courseId}.`
64
61
  );
65
- return reviews.rows
66
- .filter((r) => {
67
- if (r.id.startsWith(REVIEW_PREFIX)) {
68
- const date = moment.utc(r.id.substr(REVIEW_PREFIX.length), REVIEW_TIME_FORMAT);
69
- if (targetDate.isAfter(date)) {
70
- if (this._courseId === undefined || r.doc!.courseId === this._courseId) {
71
- return true;
72
- }
73
- }
74
- }
75
- })
76
- .map((r) => r.doc!);
62
+
63
+ return allReviews.filter((review: ScheduledCard) => {
64
+ const reviewTime = moment.utc(review.reviewTime);
65
+ return targetDate.isAfter(reviewTime);
66
+ });
77
67
  }
78
68
  }
@@ -17,6 +17,11 @@ export class NoOpSyncStrategy implements SyncStrategy {
17
17
  return getLocalUserDB(username);
18
18
  }
19
19
 
20
+ getWriteDB(username: string): PouchDB.Database {
21
+ // In static mode, always write to local database
22
+ return getLocalUserDB(username);
23
+ }
24
+
20
25
  startSync(_localDB: PouchDB.Database, _remoteDB: PouchDB.Database): void {
21
26
  // No-op - in static mode, local and remote are the same database instance
22
27
  // PouchDB sync with itself is harmless and efficient
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { StaticCourseManifest, ChunkMetadata } from '../../util/packer/types';
4
4
  import { logger } from '../../util/logger';
5
- import { DocType } from '@db/core';
5
+ import { DocType, DocTypePrefixes } from '@db/core';
6
6
 
7
7
  // Browser-compatible path utilities
8
8
  const pathUtils = {
@@ -151,57 +151,38 @@ export class StaticDataUnpacker {
151
151
  return (await this.loadIndex('tags')) as TagsIndex;
152
152
  }
153
153
 
154
+ private getDocTypeFromId(id: string): DocType | undefined {
155
+ for (const docTypeKey in DocTypePrefixes) {
156
+ const prefix = DocTypePrefixes[docTypeKey as DocType];
157
+ if (id.startsWith(`${prefix}-`)) {
158
+ return docTypeKey as DocType;
159
+ }
160
+ }
161
+ return undefined;
162
+ }
163
+
154
164
  /**
155
165
  * Find which chunk contains a specific document ID
156
166
  */
157
167
  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
+ const expectedDocType = this.getDocTypeFromId(docId);
168
169
 
169
- if (expectedDocType !== undefined) {
170
- // Use chunk filtering by docType for documents with recognized prefixes
170
+ if (expectedDocType) {
171
171
  const typeChunks = this.manifest.chunks.filter((c) => c.docType === expectedDocType);
172
172
 
173
173
  for (const chunk of typeChunks) {
174
174
  if (docId >= chunk.startKey && docId <= chunk.endKey) {
175
- // Verify document actually exists in chunk
176
175
  const exists = await this.verifyDocumentInChunk(docId, chunk);
177
176
  if (exists) {
178
177
  return chunk;
179
178
  }
180
179
  }
181
180
  }
182
-
183
- return undefined;
184
181
  } 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) {
182
+ // Fallback for documents without recognized prefixes (e.g., CourseConfig, or old documents)
183
+ // This part remains for backward compatibility and non-prefixed documents.
184
+ // It's less efficient but necessary if not all document types are prefixed.
185
+ for (const chunk of this.manifest.chunks) {
205
186
  if (docId >= chunk.startKey && docId <= chunk.endKey) {
206
187
  // Verify document actually exists in chunk
207
188
  const exists = await this.verifyDocumentInChunk(docId, chunk);
@@ -227,6 +208,7 @@ export class StaticDataUnpacker {
227
208
 
228
209
  return undefined;
229
210
  }
211
+ return undefined;
230
212
  }
231
213
 
232
214
  /**
@@ -15,6 +15,7 @@ import { DataLayerResult } from '../../core/types/db';
15
15
  import { ContentNavigationStrategyData } from '../../core/types/contentNavigationStrategy';
16
16
  import { ScheduledCard } from '../../core/types/user';
17
17
  import { Navigators } from '../../core/navigators';
18
+ import { logger } from '../../util/logger';
18
19
 
19
20
  export class StaticCourseDB implements CourseDBInterface {
20
21
  constructor(
@@ -41,10 +42,15 @@ export class StaticCourseDB implements CourseDBInterface {
41
42
  }
42
43
 
43
44
  async getCourseInfo(): Promise<CourseInfo> {
44
- // This would need to be pre-computed in the manifest
45
+ // Count only cards, not all documents
46
+ // Use chunks metadata to count card documents specifically
47
+ const cardCount = this.manifest.chunks
48
+ .filter((chunk) => chunk.docType === DocType.CARD)
49
+ .reduce((total, chunk) => total + chunk.documentCount, 0);
50
+
45
51
  return {
46
- cardCount: 0, // Would come from manifest
47
- registeredUsers: 0,
52
+ cardCount,
53
+ registeredUsers: 0, // Always 0 in static mode
48
54
  };
49
55
  }
50
56
 
@@ -158,13 +164,61 @@ export class StaticCourseDB implements CourseDBInterface {
158
164
  }));
159
165
  }
160
166
 
161
- async getAppliedTags(_cardId: string): Promise<PouchDB.Query.Response<TagStub>> {
162
- // Would need to query the tag index
163
- return {
164
- total_rows: 0,
165
- offset: 0,
166
- rows: [],
167
- };
167
+ async getAppliedTags(cardId: string): Promise<PouchDB.Query.Response<TagStub>> {
168
+ try {
169
+ const tagsIndex = await this.unpacker.getTagsIndex();
170
+ const cardTags = tagsIndex.byCard[cardId] || [];
171
+
172
+ const rows = await Promise.all(
173
+ cardTags.map(async (tagName) => {
174
+ const tagId = `${DocType.TAG}-${tagName}`;
175
+
176
+ try {
177
+ // Try to get the full tag document
178
+ const tagDoc = await this.unpacker.getDocument(tagId);
179
+ return {
180
+ id: tagId,
181
+ key: cardId,
182
+ value: {
183
+ name: tagDoc.name,
184
+ snippet: tagDoc.snippet,
185
+ count: tagDoc.taggedCards?.length || 0,
186
+ },
187
+ };
188
+ } catch (error) {
189
+ if (error && (error as PouchDB.Core.Error).status === 404) {
190
+ logger.warn(`Tag document not found for ${tagName}, creating stub`);
191
+ } else {
192
+ logger.error(`Error getting tag document for ${tagName}:`, error);
193
+ throw error;
194
+ }
195
+ // If tag document not found, create a minimal stub
196
+ return {
197
+ id: tagId,
198
+ key: cardId,
199
+ value: {
200
+ name: tagName,
201
+ snippet: `Tag: ${tagName}`,
202
+ count: tagsIndex.byTag[tagName]?.length || 0,
203
+ },
204
+ };
205
+ }
206
+ })
207
+ );
208
+
209
+ return {
210
+ total_rows: rows.length,
211
+ offset: 0,
212
+ rows,
213
+ };
214
+ } catch (error) {
215
+ logger.error(`Error getting applied tags for card ${cardId}:`, error);
216
+ return {
217
+ total_rows: 0,
218
+ offset: 0,
219
+ rows: [],
220
+ };
221
+ }
168
222
  }
169
223
 
170
224
  async addTagToCard(_cardId: string, _tagId: string): Promise<PouchDB.Core.Response> {
@@ -188,12 +242,76 @@ export class StaticCourseDB implements CourseDBInterface {
188
242
  }
189
243
 
190
244
  async getCourseTagStubs(): Promise<PouchDB.Core.AllDocsResponse<Tag>> {
191
- // Would query all tag documents
192
- return {
193
- total_rows: 0,
194
- offset: 0,
195
- rows: [],
196
- };
245
+ try {
246
+ const tagsIndex = await this.unpacker.getTagsIndex();
247
+
248
+ if (!tagsIndex || !tagsIndex.byTag) {
249
+ logger.warn('Tags index not found or empty');
250
+ return {
251
+ total_rows: 0,
252
+ offset: 0,
253
+ rows: [],
254
+ };
255
+ }
256
+
257
+ // Create tag stubs from the index
258
+ const tagNames = Object.keys(tagsIndex.byTag);
259
+ const rows = await Promise.all(
260
+ tagNames.map(async (tagName) => {
261
+ const cardIds = tagsIndex.byTag[tagName] || [];
262
+ const tagId = `${DocType.TAG}-${tagName}`;
263
+
264
+ try {
265
+ // Try to get the full tag document
266
+ const tagDoc = await this.unpacker.getDocument(tagId);
267
+ return {
268
+ id: tagId,
269
+ key: tagId,
270
+ value: { rev: '1-static' },
271
+ doc: tagDoc,
272
+ };
273
+ } catch (error) {
274
+ // If tag document not found, create a minimal stub
275
+ if (error && (error as PouchDB.Core.Error).status === 404) {
276
+ logger.warn(`Tag document not found for ${tagName}, creating stub`);
277
+ const stubDoc = {
278
+ _id: tagId,
279
+ _rev: '1-static',
280
+ course: this.courseId,
281
+ docType: DocType.TAG,
282
+ name: tagName,
283
+ snippet: `Tag: ${tagName}`,
284
+ wiki: '',
285
+ taggedCards: cardIds,
286
+ author: 'system',
287
+ };
288
+ return {
289
+ id: tagId,
290
+ key: tagId,
291
+ value: { rev: '1-static' },
292
+ doc: stubDoc,
293
+ };
294
+ } else {
295
+ logger.error(`Error getting tag document for ${tagName}:`, error);
296
+ throw error;
297
+ }
298
+ }
299
+ })
300
+ );
301
+
302
+ return {
303
+ total_rows: rows.length,
304
+ offset: 0,
305
+ rows,
306
+ };
307
+ } catch (error) {
308
+ logger.error('Failed to get course tag stubs:', error);
309
+ return {
310
+ total_rows: 0,
311
+ offset: 0,
312
+ rows: [],
313
+ };
314
+ }
197
315
  }
198
316
 
199
317
  async addNote(
@@ -256,7 +374,7 @@ export class StaticCourseDB implements CourseDBInterface {
256
374
  }
257
375
 
258
376
  // Attachment helper methods (internal use, not part of interface)
259
-
377
+
260
378
  /**
261
379
  * Get attachment URL for a document and attachment name
262
380
  * Internal helper method for static attachment serving
@@ -0,0 +1,53 @@
1
+ // Test suite for cross-platform data directory utilities
2
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
3
+ import * as os from 'os';
4
+ import * as path from 'path';
5
+ import { getAppDataDirectory, getDbPath, ensureAppDataDirectory } from './dataDirectory';
6
+
7
+ describe('dataDirectory utilities', () => {
8
+ const originalHomedir = os.homedir;
9
+ const testHome = '/test/home';
10
+
11
+ beforeEach(() => {
12
+ // Mock os.homedir for consistent testing
13
+ (os as any).homedir = () => testHome;
14
+ });
15
+
16
+ afterEach(() => {
17
+ // Restore original function
18
+ os.homedir = originalHomedir;
19
+ });
20
+
21
+ describe('getAppDataDirectory', () => {
22
+ it('should return correct path using home directory', () => {
23
+ const result = getAppDataDirectory();
24
+ expect(result).toBe(path.join(testHome, '.tuilder'));
25
+ });
26
+ });
27
+
28
+ describe('getDbPath', () => {
29
+ it('should return correct database path', () => {
30
+ const dbName = 'userdb-testuser';
31
+ const result = getDbPath(dbName);
32
+ expect(result).toBe(path.join(testHome, '.tuilder', dbName));
33
+ });
34
+
35
+ it('should handle special characters in username', () => {
36
+ const dbName = 'userdb-test@user.com';
37
+ const result = getDbPath(dbName);
38
+ expect(result).toBe(path.join(testHome, '.tuilder', dbName));
39
+ });
40
+ });
41
+
42
+ describe('ensureAppDataDirectory', () => {
43
+ it('should handle path creation gracefully', async () => {
44
+ // Note: This test doesn't actually create directories since we're testing logic
45
+ // In a real test environment, you'd want to use a temp directory
46
+ expect(typeof ensureAppDataDirectory).toBe('function');
47
+
48
+ // Test the function exists and returns a promise
49
+ const result = ensureAppDataDirectory();
50
+ expect(result).toBeInstanceOf(Promise);
51
+ });
52
+ });
53
+ });
@@ -0,0 +1,52 @@
1
+ // Cross-platform data directory utilities for PouchDB
2
+ // Provides OS-appropriate application data directories
3
+
4
+ import * as fs from 'fs';
5
+ import * as path from 'path';
6
+ import * as os from 'os';
7
+ import { logger } from './tuiLogger';
8
+
9
+ /**
10
+ * Get the application data directory for the current platform
11
+ * Uses ~/.tuilder as requested by user for simplicity
12
+ */
13
+ export function getAppDataDirectory(): string {
14
+ return path.join(os.homedir(), '.tuilder');
15
+ }
16
+
17
+ /**
18
+ * Ensure the application data directory exists
19
+ * Creates directory recursively if it doesn't exist
20
+ */
21
+ export async function ensureAppDataDirectory(): Promise<string> {
22
+ const appDataDir = getAppDataDirectory();
23
+
24
+ try {
25
+ await fs.promises.mkdir(appDataDir, { recursive: true });
26
+ } catch (err: any) {
27
+ if (err.code !== 'EEXIST') {
28
+ throw new Error(`Failed to create app data directory ${appDataDir}: ${err.message}`);
29
+ }
30
+ }
31
+
32
+ return appDataDir;
33
+ }
34
+
35
+ /**
36
+ * Get the full path for a PouchDB database file
37
+ * @param dbName - The database name (e.g., 'userdb-Colin')
38
+ */
39
+ export function getDbPath(dbName: string): string {
40
+ return path.join(getAppDataDirectory(), dbName);
41
+ }
42
+
43
+ /**
44
+ * Initialize data directory for PouchDB usage
45
+ * Should be called once at application startup
46
+ */
47
+ export async function initializeDataDirectory(): Promise<void> {
48
+ await ensureAppDataDirectory();
49
+
50
+ // Log initialization
51
+ logger.info(`PouchDB data directory initialized: ${getAppDataDirectory()}`);
52
+ }
package/src/util/index.ts CHANGED
@@ -1,2 +1,5 @@
1
1
  export * from './Loggable';
2
2
  export * from './packer';
3
+ export * from './migrator';
4
+ export * from './dataDirectory';
5
+ export * from './tuiLogger';