@vue-skuilder/db 0.1.33 → 0.1.34

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/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
7
- "version": "0.1.33",
7
+ "version": "0.1.34",
8
8
  "description": "Database layer for vue-skuilder",
9
9
  "main": "dist/index.js",
10
10
  "module": "dist/index.mjs",
@@ -48,7 +48,7 @@
48
48
  },
49
49
  "dependencies": {
50
50
  "@nilock2/pouchdb-authentication": "^1.0.2",
51
- "@vue-skuilder/common": "0.1.33",
51
+ "@vue-skuilder/common": "0.1.34",
52
52
  "cross-fetch": "^4.1.0",
53
53
  "moment": "^2.29.4",
54
54
  "pouchdb": "^9.0.0",
@@ -62,5 +62,5 @@
62
62
  "vite": "^8.0.0",
63
63
  "vitest": "^4.1.0"
64
64
  },
65
- "stableVersion": "0.1.33"
65
+ "stableVersion": "0.1.34"
66
66
  }
@@ -131,7 +131,29 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
131
131
  const now = moment.utc();
132
132
 
133
133
  // Filter to only cards that are actually due
134
- const dueReviews = reviews.filter((r) => now.isAfter(moment.utc(r.reviewTime)));
134
+ let dueReviews = reviews.filter((r) => now.isAfter(moment.utc(r.reviewTime)));
135
+
136
+ // Remove scheduled reviews for cards tagged srs:skip (e.g. intro cards).
137
+ // These were scheduled before the tag convention existed — clean them up.
138
+ if (dueReviews.length > 0) {
139
+ const dueCardIds = [...new Set(dueReviews.map((r) => r.cardId))];
140
+ const tagsByCard = await this.course!.getAppliedTagsBatch(dueCardIds);
141
+ const skippedReviewIds: string[] = [];
142
+ dueReviews = dueReviews.filter((r) => {
143
+ const tags = tagsByCard.get(r.cardId) ?? [];
144
+ if (tags.includes('srs:skip')) {
145
+ skippedReviewIds.push(r._id);
146
+ return false;
147
+ }
148
+ return true;
149
+ });
150
+ if (skippedReviewIds.length > 0) {
151
+ logger.info(`[SRS] Removing ${skippedReviewIds.length} scheduled reviews for srs:skip cards`);
152
+ for (const id of skippedReviewIds) {
153
+ void this.user!.removeScheduledCardReview(id);
154
+ }
155
+ }
156
+ }
135
157
 
136
158
  // Compute backlog pressure - applies globally to all reviews
137
159
  const backlogPressure = this.computeBacklogPressure(dueReviews.length);
@@ -889,18 +889,21 @@ Currently logged-in as ${this._username}.`
889
889
  * @param course_id optional specification of individual course
890
890
  */
891
891
  async getSeenCards(course_id?: string) {
892
- let prefix = DocTypePrefixes[DocType.CARDRECORD];
892
+ // Doc IDs follow the pattern: cardH-{courseId}-{cardId}
893
+ // (see getCardHistoryID in core/util)
894
+ const basePrefix = DocTypePrefixes[DocType.CARDRECORD];
895
+ let filterPrefix = basePrefix;
893
896
  if (course_id) {
894
- prefix += course_id;
897
+ filterPrefix += `-${course_id}-`;
895
898
  }
896
- const docs = await filterAllDocsByPrefix(this.localDB, prefix, {
899
+ const docs = await filterAllDocsByPrefix(this.localDB, filterPrefix, {
897
900
  include_docs: false,
898
901
  });
899
- // const docs = await this.localDB.allDocs({});
900
902
  const ret: PouchDB.Core.DocumentId[] = [];
901
903
  docs.rows.forEach((row) => {
902
- if (row.id.startsWith(DocTypePrefixes[DocType.CARDRECORD])) {
903
- ret.push(row.id.substr(DocTypePrefixes[DocType.CARDRECORD].length));
904
+ if (row.id.startsWith(filterPrefix)) {
905
+ // Strip the full prefix to return bare cardId
906
+ ret.push(row.id.substr(filterPrefix.length));
904
907
  }
905
908
  });
906
909
  return ret;
@@ -124,6 +124,16 @@ export class SessionController<TView = unknown> extends Loggable {
124
124
  */
125
125
  private _defaultBatchLimit: number = 20;
126
126
 
127
+ /**
128
+ * Maximum number of reviews enqueued at session start. Reviews live
129
+ * outside the replan flow — the queue drains via consumption and is
130
+ * not refilled mid-session. The session timer caps total review
131
+ * exposure, so overfilling here is intentional. Default is generous
132
+ * to accommodate Anki-style power users with hundreds of due reviews;
133
+ * apps targeting nimbler sessions should override via constructor.
134
+ */
135
+ private _initialReviewCap: number = 200;
136
+
127
137
  private sources: StudyContentSource[];
128
138
  // dataLayer and getViewComponent now injected into CardHydrationService
129
139
  private _sessionRecord: StudySessionRecord[] = [];
@@ -208,6 +218,8 @@ export class SessionController<TView = unknown> extends Loggable {
208
218
  * @param options.defaultBatchLimit - Default pipeline batch size (default: 20).
209
219
  * Smaller values for newer users cause more frequent replans, keeping plans
210
220
  * aligned with rapidly-changing user state.
221
+ * @param options.initialReviewCap - Max reviews loaded at session start (default: 200).
222
+ * Applied only on initial planning; replans do not refill the review queue.
211
223
  */
212
224
  constructor(
213
225
  sources: StudyContentSource[],
@@ -215,7 +227,7 @@ export class SessionController<TView = unknown> extends Loggable {
215
227
  dataLayer: DataLayerProvider,
216
228
  getViewComponent: (viewId: string) => TView,
217
229
  mixer?: SourceMixer,
218
- options?: { defaultBatchLimit?: number }
230
+ options?: { defaultBatchLimit?: number; initialReviewCap?: number }
219
231
  ) {
220
232
  super();
221
233
 
@@ -242,11 +254,15 @@ export class SessionController<TView = unknown> extends Loggable {
242
254
  if (options?.defaultBatchLimit !== undefined) {
243
255
  this._defaultBatchLimit = options.defaultBatchLimit;
244
256
  }
257
+ if (options?.initialReviewCap !== undefined) {
258
+ this._initialReviewCap = options.initialReviewCap;
259
+ }
245
260
 
246
261
  this.log(`Session constructed:
247
262
  startTime: ${this.startTime}
248
263
  endTime: ${this.endTime}
249
- defaultBatchLimit: ${this._defaultBatchLimit}`);
264
+ defaultBatchLimit: ${this._defaultBatchLimit}
265
+ initialReviewCap: ${this._initialReviewCap}`);
250
266
  }
251
267
 
252
268
  private tick() {
@@ -354,18 +370,21 @@ export class SessionController<TView = unknown> extends Loggable {
354
370
  return this._replanPromise;
355
371
  }
356
372
 
357
- // Auto-exclude the currently-displayed card so the replan doesn't
358
- // surface it again (avoids showing the same card twice in a row).
373
+ // Exclude all cards already presented this session. The pipeline may
374
+ // not yet see their encounter records (async writes), so without this
375
+ // they can re-enter newQ via replaceAll and cause duplicates.
376
+ if (!opts.hints) opts.hints = {};
377
+ const hints = opts.hints;
378
+ const excludeSet = new Set(hints.excludeCards ?? []);
379
+
359
380
  if (this._currentCard?.item.cardID) {
360
- const currentId = this._currentCard.item.cardID;
361
- if (!opts.hints) opts.hints = {};
362
- const hints = opts.hints;
363
- const excludeCards = hints.excludeCards ?? [];
364
- if (!excludeCards.includes(currentId)) {
365
- excludeCards.push(currentId);
366
- }
367
- hints.excludeCards = excludeCards;
381
+ excludeSet.add(this._currentCard.item.cardID);
368
382
  }
383
+ for (const rec of this._sessionRecord) {
384
+ excludeSet.add(rec.card.card_id);
385
+ }
386
+
387
+ hints.excludeCards = [...excludeSet];
369
388
 
370
389
  // Forward hints to all sources (CourseDB stashes them, Pipeline consumes them)
371
390
  if (opts.hints) {
@@ -583,7 +602,11 @@ export class SessionController<TView = unknown> extends Loggable {
583
602
  }): Promise<number> {
584
603
  const replan = options?.replan ?? false;
585
604
  const additive = options?.additive ?? false;
586
- const limit = options?.limit ?? this._defaultBatchLimit;
605
+ const newLimit = options?.limit ?? this._defaultBatchLimit;
606
+ // Initial planning inflates the per-source budget so reviews can fill up
607
+ // to _initialReviewCap independently of the new-card budget. Replans
608
+ // never touch reviewQ, so the inflation is unnecessary there.
609
+ const fetchLimit = replan ? newLimit : newLimit + this._initialReviewCap;
587
610
 
588
611
  // Collect batches from each source
589
612
  const batches: SourceBatch[] = [];
@@ -592,7 +615,7 @@ export class SessionController<TView = unknown> extends Loggable {
592
615
  const source = this.sources[i];
593
616
  try {
594
617
  // Fetch weighted cards for mixing
595
- const weighted = (await source.getWeightedCards!(limit)).cards;
618
+ const weighted = (await source.getWeightedCards!(fetchLimit)).cards;
596
619
 
597
620
  batches.push({
598
621
  sourceIndex: i,
@@ -621,7 +644,7 @@ export class SessionController<TView = unknown> extends Loggable {
621
644
  }
622
645
 
623
646
  // Mix weighted cards across sources using configured strategy
624
- const mixedWeighted = this.mixer.mix(batches, limit * this.sources.length);
647
+ const mixedWeighted = this.mixer.mix(batches, fetchLimit * this.sources.length);
625
648
 
626
649
  // Capture mixer run for debugging - fetch course names
627
650
  const sourceIds = batches.map((b) => {
@@ -643,20 +666,27 @@ export class SessionController<TView = unknown> extends Loggable {
643
666
  );
644
667
  const sourceNames = sourceIds.map((id) => this.courseNameCache.get(id));
645
668
  const quotaPerSource =
646
- this.mixer instanceof QuotaRoundRobinMixer ? Math.ceil((limit * this.sources.length) / batches.length) : undefined;
669
+ this.mixer instanceof QuotaRoundRobinMixer ? Math.ceil((fetchLimit * this.sources.length) / batches.length) : undefined;
647
670
  captureMixerRun(
648
671
  this.mixer.constructor.name,
649
672
  batches,
650
673
  sourceIds,
651
674
  sourceNames,
652
- limit * this.sources.length,
675
+ fetchLimit * this.sources.length,
653
676
  quotaPerSource,
654
677
  mixedWeighted
655
678
  );
656
679
 
657
- // Split mixed results by card origin
658
- const reviewWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === 'review');
659
- const newWeighted = mixedWeighted.filter((w) => getCardOrigin(w) === 'new');
680
+ // Split mixed results by card origin, then apply per-origin caps. The
681
+ // pre-mixer fetch is inflated to fit both budgets; trimming here keeps
682
+ // newQ at the nimble batch size while letting reviewQ overfill up to
683
+ // _initialReviewCap (replan path discards reviewWeighted entirely).
684
+ const reviewWeighted = mixedWeighted
685
+ .filter((w) => getCardOrigin(w) === 'review')
686
+ .slice(0, this._initialReviewCap);
687
+ const newWeighted = mixedWeighted
688
+ .filter((w) => getCardOrigin(w) === 'new')
689
+ .slice(0, newLimit);
660
690
 
661
691
  logger.debug(`[reviews] got ${reviewWeighted.length} reviews from mixer`);
662
692
 
@@ -173,7 +173,11 @@ export class ResponseProcessor {
173
173
  // Only schedule and update ELO for first-time attempts
174
174
  if (cardRecord.priorAttemps === 0) {
175
175
  // Schedule the card for future review based on performance (async, non-blocking)
176
- void this.srsService.scheduleReview(history, studySessionItem);
176
+ // Cards tagged srs:skip are one-time presentations (e.g. intro cards) — no review scheduling
177
+ const skipSrs = currentCard.card.tags.includes('srs:skip');
178
+ if (!skipSrs) {
179
+ void this.srsService.scheduleReview(history, studySessionItem);
180
+ }
177
181
 
178
182
  // Parse performance (may be numeric or structured)
179
183
  const { globalScore, taggedPerformance } = this.parsePerformance(cardRecord.performance);