@vue-skuilder/db 0.2.0 → 0.2.2

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.
@@ -302,11 +302,19 @@ export class CourseDB implements CourseDBInterface {
302
302
  elo = parseInt(elo as any);
303
303
  const limit = cardLimit ? cardLimit : 25;
304
304
 
305
+ // const tQ0 = performance.now();
306
+ // NOTE: `stale: 'update_after'` was tried here and removed — it gave no
307
+ // measurable speedup (PouchDB 9 effectively ignores it for the reindex
308
+ // cost) AND it can return an empty result on the first query after a cold
309
+ // DB open (index not yet loaded), which then poisons the pool cache. The
310
+ // session pool cache (see getCardsCenteredAtELO) is what removes the
311
+ // per-run cost, so we read the view normally (always-fresh) here.
305
312
  const below: PouchDB.Query.Response<object> = await this.db.query('elo', {
306
313
  limit: Math.ceil(limit / 2),
307
314
  startkey: elo,
308
315
  descending: true,
309
316
  });
317
+ // const tBelowQ = performance.now();
310
318
 
311
319
  const aboveLimit = limit - below.rows.length;
312
320
 
@@ -314,7 +322,13 @@ export class CourseDB implements CourseDBInterface {
314
322
  limit: aboveLimit,
315
323
  startkey: elo + 1,
316
324
  });
317
- // logger.log(JSON.stringify(below));
325
+ // const tAbove = performance.now();
326
+ // [perf] parked: getCardsByELO view-query timing (below/above split)
327
+ // logger.info(
328
+ // `[perf][getCardsByELO] reqLimit=${limit} ` +
329
+ // `below=${(tBelowQ - tQ0).toFixed(0)}ms(${below.rows.length}r) ` +
330
+ // `above=${(tAbove - tBelowQ).toFixed(0)}ms(${above.rows.length}r)`
331
+ // );
318
332
 
319
333
  let cards = below.rows;
320
334
  cards = cards.concat(above.rows);
@@ -573,6 +587,8 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
573
587
 
574
588
  async addNavigationStrategy(data: ContentNavigationStrategyData): Promise<void> {
575
589
  logger.debug(`[courseDB] Adding navigation strategy: ${data._id}`);
590
+ // Strategy set changed — drop the cached navigator so it rebuilds.
591
+ this.invalidateNavigatorCache();
576
592
  // Admin write operation — use remote DB.
577
593
  return this.remoteDB.put(data).then(() => {});
578
594
  }
@@ -654,6 +670,35 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
654
670
  */
655
671
  private _pendingHints: ReplanHints | null = null;
656
672
 
673
+ /**
674
+ * Session-scoped cache of the broad ELO-neighbor pool used by
675
+ * getCardsCenteredAtELO. The `elo` view query re-indexes on first touch per
676
+ * call (PouchDB 9 ignores `stale`), so without this each plan/replan pays
677
+ * ~1.5-2s. The pool is fetched once and re-ranked against the live (roaming)
678
+ * ELO in memory on subsequent calls.
679
+ */
680
+ private _eloPoolCache: {
681
+ rows: (QualifiedCardID & { elo?: number })[];
682
+ fetchedAt: number;
683
+ } | null = null;
684
+ private readonly _eloPoolTtlMs = 5 * 60 * 1000;
685
+
686
+ /**
687
+ * Cached assembled navigator (Pipeline). createNavigator() reads strategy
688
+ * docs and builds a fresh Pipeline every call — whose internal `_tagCache`
689
+ * and `_cachedOrchestration` are designed to make replans cheap but never
690
+ * survive, because the instance is discarded each run. Caching it lets those
691
+ * caches persist across plan/replan within a session (SessionController holds
692
+ * one CourseDB instance for the session's lifetime). Rebuilt on user change,
693
+ * TTL expiry, or explicit invalidation after a strategy-doc write.
694
+ */
695
+ private _cachedNavigator: {
696
+ navigator: ContentNavigator;
697
+ userId: string;
698
+ builtAt: number;
699
+ } | null = null;
700
+ private readonly _navigatorTtlMs = 5 * 60 * 1000;
701
+
657
702
  public setEphemeralHints(hints: ReplanHints): void {
658
703
  this._pendingHints = hints;
659
704
  }
@@ -662,18 +707,60 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
662
707
  const u = await this._getCurrentUser();
663
708
 
664
709
  try {
665
- const navigator = await this.createNavigator(u);
710
+ // const tNav0 = performance.now(); // [perf] parked
711
+ const { navigator } = await this._getCachedNavigator(u);
712
+ // const tNav1 = performance.now(); // [perf] parked
666
713
  if (this._pendingHints) {
667
714
  navigator.setEphemeralHints(this._pendingHints);
668
715
  this._pendingHints = null;
669
716
  }
670
- return navigator.getWeightedCards(limit);
717
+ const result = await navigator.getWeightedCards(limit);
718
+ // const tRun = performance.now(); // [perf] parked
719
+ // [perf] parked 2026-05 (pipeline-docs-workup) — uncomment to re-measure
720
+ // logger.info(
721
+ // `[perf][courseDB] getWeightedCards(limit=${limit}): ` +
722
+ // `navigator=${(tNav1 - tNav0).toFixed(0)}ms(${navCache}) ` +
723
+ // `pipelineRun=${(tRun - tNav1).toFixed(0)}ms ` +
724
+ // `total=${(tRun - tNav0).toFixed(0)}ms`
725
+ // );
726
+ return result;
671
727
  } catch (e) {
672
728
  logger.error(`[courseDB] Error getting weighted cards: ${e}`);
673
729
  throw e;
674
730
  }
675
731
  }
676
732
 
733
+ /**
734
+ * Return the assembled navigator, reusing the cached instance when possible.
735
+ * Reuse preserves the Pipeline's per-session caches (tags, orchestration
736
+ * context) across replans, which is the dominant per-replan cost once the
737
+ * ELO-pool cost is removed. Rebuilds on user change or TTL expiry.
738
+ */
739
+ private async _getCachedNavigator(
740
+ user: UserDBInterface
741
+ ): Promise<{ navigator: ContentNavigator; cacheStatus: 'hit' | 'miss' }> {
742
+ const userId = user.getUsername();
743
+ const now = Date.now();
744
+ if (
745
+ this._cachedNavigator &&
746
+ this._cachedNavigator.userId === userId &&
747
+ now - this._cachedNavigator.builtAt < this._navigatorTtlMs
748
+ ) {
749
+ return { navigator: this._cachedNavigator.navigator, cacheStatus: 'hit' };
750
+ }
751
+ const navigator = await this.createNavigator(user);
752
+ this._cachedNavigator = { navigator, userId, builtAt: now };
753
+ return { navigator, cacheStatus: 'miss' };
754
+ }
755
+
756
+ /**
757
+ * Drop the cached navigator so the next getWeightedCards() rebuilds it.
758
+ * Call after mutating this course's navigation strategy documents.
759
+ */
760
+ public invalidateNavigatorCache(): void {
761
+ this._cachedNavigator = null;
762
+ }
763
+
677
764
  public async getCardsCenteredAtELO(
678
765
  options: {
679
766
  limit: number;
@@ -684,6 +771,9 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
684
771
  },
685
772
  filter?: (a: QualifiedCardID) => boolean
686
773
  ): Promise<StudySessionItem[]> {
774
+ // [perf] parked: getCardsCenteredAtELO rewrite banner
775
+ // logger.info('[perf][run] getCardsCenteredAtELO rewrite (session pool cache + in-memory recenter)');
776
+ // const tCelo0 = performance.now();
687
777
  let targetElo: number;
688
778
 
689
779
  if (options.elo === 'user') {
@@ -706,26 +796,65 @@ above:\n${above.rows.map((r) => `\t${r.id}-${r.key}\n`)}`;
706
796
  targetElo = options.elo;
707
797
  }
708
798
 
709
- let cards: (QualifiedCardID & { elo?: number })[] = [];
710
- let mult: number = 4;
711
- let previousCount: number = -1;
712
- let newCount: number = 0;
799
+ // const tReg = performance.now();
800
+
801
+ // Broad neighbor pool fetched once per session and re-used. We over-fetch
802
+ // (POOL_SIZE >> limit) so that the in-memory active-card filter and the
803
+ // slowly-roaming ELO both have ample headroom before a refetch is needed.
804
+ const POOL_SIZE = Math.max(2000, options.limit * 4);
805
+ const nowMs = Date.now();
806
+ let cacheStatus: 'hit' | 'miss' | 'refresh' = 'hit';
807
+
808
+ if (!this._eloPoolCache || nowMs - this._eloPoolCache.fetchedAt > this._eloPoolTtlMs) {
809
+ // MISS: pay the (reindexing) view query once, then cache the raw pool.
810
+ // Guard: never cache an EMPTY pool. A cold-DB-open or sync-race fetch can
811
+ // transiently return [], and caching it would starve the session for the
812
+ // whole TTL. Leaving the cache untouched lets the next call retry.
813
+ const fetched = await this.getCardsByELO(targetElo, POOL_SIZE);
814
+ if (fetched.length > 0) {
815
+ this._eloPoolCache = { rows: fetched, fetchedAt: nowMs };
816
+ }
817
+ cacheStatus = 'miss';
818
+ }
713
819
 
714
- while (cards.length < options.limit && newCount !== previousCount) {
715
- cards = await this.getCardsByELO(targetElo, mult * options.limit);
716
- previousCount = newCount;
717
- newCount = cards.length;
820
+ // Apply the (fresh) caller filter, then re-center against the *current* ELO.
821
+ // Returns a new array each call — the cached pool is never mutated, and the
822
+ // ranking reflects the live ELO even as it drifts within a session.
823
+ const rankAgainstCurrentElo = (): (QualifiedCardID & { elo?: number })[] => {
824
+ const raw = this._eloPoolCache?.rows ?? [];
825
+ const survivors = filter ? raw.filter((c) => filter(c)) : raw;
826
+ return survivors
827
+ .map((c) => ({ ...c }))
828
+ .sort(
829
+ (a, b) =>
830
+ Math.abs((a.elo ?? targetElo) - targetElo) -
831
+ Math.abs((b.elo ?? targetElo) - targetElo)
832
+ );
833
+ };
718
834
 
719
- logger.debug(`Found ${cards.length} elo neighbor cards...`);
835
+ let cards = rankAgainstCurrentElo();
720
836
 
721
- if (filter) {
722
- cards = cards.filter(filter);
723
- logger.debug(`Filtered to ${cards.length} cards...`);
837
+ // Refetch once if the pool can't satisfy the limit — either the active-card
838
+ // filter has grown past pool coverage (hit), or the pool is missing because
839
+ // a prior fetch came back empty (cold open / sync race). A miss that cached
840
+ // a non-empty-but-small pool (genuinely small course) is left alone.
841
+ if (cards.length < options.limit && (cacheStatus === 'hit' || !this._eloPoolCache)) {
842
+ const fetched = await this.getCardsByELO(targetElo, POOL_SIZE);
843
+ if (fetched.length > 0) {
844
+ this._eloPoolCache = { rows: fetched, fetchedAt: nowMs };
724
845
  }
725
-
726
- mult *= 2;
846
+ cards = rankAgainstCurrentElo();
847
+ cacheStatus = 'refresh';
727
848
  }
728
849
 
850
+ // [perf] parked: centeredAtELO regDoc / pool-cache timing
851
+ // logger.info(
852
+ // `[perf][centeredAtELO] regDoc=${(tReg - tCelo0).toFixed(0)}ms ` +
853
+ // `cache=${cacheStatus} build=${(performance.now() - tReg).toFixed(0)}ms ` +
854
+ // `poolRaw=${this._eloPoolCache?.rows.length ?? 0} postFilter=${cards.length} ` +
855
+ // `limit=${options.limit} targetElo=${targetElo}`
856
+ // );
857
+
729
858
  const selectedCards: {
730
859
  courseID: string;
731
860
  cardID: string;
@@ -478,7 +478,13 @@ export class SessionController<TView = unknown> extends Loggable {
478
478
  this.log(`[Replan] Card guarantee set to ${this._minCardsGuarantee}`);
479
479
  }
480
480
 
481
+ // [perf] parked 2026-05 (pipeline-docs-workup) — uncomment to re-measure
482
+ // const tReplan0 = performance.now();
481
483
  await this._executeReplan(opts);
484
+ // logger.info(
485
+ // `[perf][SessionController] replan${labelTag} (limit=${opts.limit ?? 'default'}, ` +
486
+ // `mode=${opts.mode ?? 'replace'}) took ${(performance.now() - tReplan0).toFixed(0)}ms`
487
+ // );
482
488
  }
483
489
 
484
490
  /**
@@ -535,6 +541,20 @@ export class SessionController<TView = unknown> extends Loggable {
535
541
  */
536
542
  private static readonly WELL_INDICATED_SCORE = 0.10;
537
543
 
544
+ /**
545
+ * newQ length at or below which the opportunistic depletion-prefetch
546
+ * fires. Sets the lead time available for the background replan to land
547
+ * before the user actually empties the queue and falls into the
548
+ * (synchronous) wedge-breaker path.
549
+ *
550
+ * Set to a small absolute value (not a fraction of batch limit) because
551
+ * pipeline latency is roughly fixed regardless of batch size — what
552
+ * matters is "how many cards of user-pace do we have left." 3 cards
553
+ * × ~3-5s/card = ~10-15s of lead time, comfortably exceeding typical
554
+ * pipeline latency.
555
+ */
556
+ private static readonly DEPLETION_PREFETCH_THRESHOLD = 3;
557
+
538
558
  /**
539
559
  * Internal replan execution. Runs the pipeline, builds a new newQ,
540
560
  * atomically swaps it in, and triggers hydration for the new contents.
@@ -677,6 +697,7 @@ export class SessionController<TView = unknown> extends Loggable {
677
697
  additive?: boolean;
678
698
  limit?: number;
679
699
  }): Promise<number> {
700
+ // const tGwc0 = performance.now(); // [perf] parked
680
701
  const replan = options?.replan ?? false;
681
702
  const additive = options?.additive ?? false;
682
703
  const newLimit = options?.limit ?? this._defaultBatchLimit;
@@ -707,6 +728,8 @@ export class SessionController<TView = unknown> extends Loggable {
707
728
  }
708
729
  }
709
730
 
731
+ // const tSources = performance.now(); // [perf] parked
732
+
710
733
  // Verify we got content from at least one source
711
734
  if (batches.length === 0) {
712
735
  if (replan) {
@@ -722,6 +745,7 @@ export class SessionController<TView = unknown> extends Loggable {
722
745
 
723
746
  // Mix weighted cards across sources using configured strategy
724
747
  const mixedWeighted = this.mixer.mix(batches, fetchLimit * this.sources.length);
748
+ // const tMixed = performance.now(); // [perf] parked
725
749
 
726
750
  // Capture mixer run for debugging - fetch course names
727
751
  const sourceIds = batches.map((b) => {
@@ -820,6 +844,18 @@ export class SessionController<TView = unknown> extends Loggable {
820
844
  }
821
845
 
822
846
  this.log(report);
847
+
848
+ // [perf] parked: getWeightedContent stage timing
849
+ // const tEnd = performance.now();
850
+ // logger.info(
851
+ // `[perf][SessionController] getWeightedContent(replan=${replan}): ` +
852
+ // `sources=${(tSources - tGwc0).toFixed(0)}ms ` +
853
+ // `mix=${(tMixed - tSources).toFixed(0)}ms ` +
854
+ // `post=${(tEnd - tMixed).toFixed(0)}ms ` +
855
+ // `total=${(tEnd - tGwc0).toFixed(0)}ms ` +
856
+ // `[sources=${this.sources.length} fetchLimit=${fetchLimit} newLimit=${newLimit}]`
857
+ // );
858
+
823
859
  return wellIndicated;
824
860
  }
825
861
 
@@ -924,6 +960,10 @@ export class SessionController<TView = unknown> extends Loggable {
924
960
  public async nextCard(
925
961
  action: SessionAction = 'dismiss-success'
926
962
  ): Promise<HydratedCard<TView> | null> {
963
+ // [perf] parked: nextCard provenance/timing (awaitedReplan, wedgeRuns)
964
+ // const tNext0 = performance.now();
965
+ // let awaitedInFlightReplan = false;
966
+ // let wedgeRuns = 0;
927
967
  // dismiss (or sort to failedQ) the current card
928
968
  this.dismissCurrentCard(action);
929
969
 
@@ -945,6 +985,7 @@ export class SessionController<TView = unknown> extends Loggable {
945
985
  this.failedQ.length === 0
946
986
  ) {
947
987
  this.log('nextCard: queues empty, awaiting in-flight replan before drawing');
988
+ // awaitedInFlightReplan = true; // [perf] parked
948
989
  await this._replanPromise;
949
990
  }
950
991
 
@@ -970,8 +1011,16 @@ export class SessionController<TView = unknown> extends Loggable {
970
1011
  // Opportunistic depletion: newQ running dry → background prefetch.
971
1012
  // No latch — if this fires repeatedly when the pipeline keeps coming
972
1013
  // back empty, the wedge-breaker's local backoff handles spin protection.
1014
+ //
1015
+ // Threshold sized to give the pipeline enough lead time to land the
1016
+ // replan before the user empties the queue. Previously hardcoded to 1,
1017
+ // which raced user pace against pipeline latency — fast users would hit
1018
+ // an empty queue mid-session and feel the wedge-breaker's synchronous
1019
+ // pipeline call as a "hang". DEPLETION_PREFETCH_THRESHOLD trades a
1020
+ // slightly earlier prefetch (which is anyway desirable for content
1021
+ // freshness) for a much more reliable lead time.
973
1022
  if (
974
- this.newQ.length <= 1 &&
1023
+ this.newQ.length <= SessionController.DEPLETION_PREFETCH_THRESHOLD &&
975
1024
  this._secondsRemaining > 0 &&
976
1025
  !this._replanPromise
977
1026
  ) {
@@ -1031,6 +1080,7 @@ export class SessionController<TView = unknown> extends Loggable {
1031
1080
  `Running pipeline (attempt ${wedgeEmptyStreak + 1}/${WEDGE_MAX_EMPTY_STREAK}).`
1032
1081
  );
1033
1082
  await this._replanUncoalesced({ label: 'wedge-breaker' });
1083
+ // wedgeRuns++; // [perf] parked
1034
1084
  if (
1035
1085
  this.newQ.length === 0 &&
1036
1086
  this.reviewQ.length === 0 &&
@@ -1093,6 +1143,11 @@ export class SessionController<TView = unknown> extends Loggable {
1093
1143
  // Snapshot queue state
1094
1144
  snapshotQueues(this.reviewQ.length, this.newQ.length, this.failedQ.length);
1095
1145
 
1146
+ // [perf] parked: per-draw nextCard timing
1147
+ // logger.info(
1148
+ // `[perf][nextCard] -> ${card.item.cardID} in ${(performance.now() - tNext0).toFixed(0)}ms ` +
1149
+ // `(awaitedReplan=${awaitedInFlightReplan} wedgeRuns=${wedgeRuns})`
1150
+ // );
1096
1151
  return card;
1097
1152
  }
1098
1153
 
@@ -163,6 +163,9 @@ export class CardHydrationService<TView = unknown> {
163
163
  }
164
164
 
165
165
  this.hydrationInProgress = true;
166
+ // [perf] parked 2026-05 (pipeline-docs-workup) — batch hydration timing
167
+ // const tFill0 = performance.now();
168
+ // let hydratedThisBatch = 0;
166
169
 
167
170
  try {
168
171
  const itemsToHydrate = this.getItemsToHydrate();
@@ -175,12 +178,20 @@ export class CardHydrationService<TView = unknown> {
175
178
 
176
179
  try {
177
180
  await this.hydrateCard(item);
181
+ // hydratedThisBatch++; // [perf] parked
178
182
  } catch (e) {
179
183
  logger.error(`[CardHydrationService] Error hydrating card ${item.cardID}:`, e);
180
184
  }
181
185
  }
182
186
  } finally {
183
187
  this.hydrationInProgress = false;
188
+ // [perf] parked: batch hydration timing
189
+ // if (hydratedThisBatch > 0) {
190
+ // logger.info(
191
+ // `[perf][Hydrate] batch: hydrated ${hydratedThisBatch} card(s) in ` +
192
+ // `${(performance.now() - tFill0).toFixed(0)}ms`
193
+ // );
194
+ // }
184
195
  }
185
196
  }
186
197
 
@@ -195,11 +206,13 @@ export class CardHydrationService<TView = unknown> {
195
206
  this.hydrationInFlight.add(item.cardID);
196
207
 
197
208
  try {
209
+ // const tH0 = performance.now(); // [perf] parked
198
210
  const courseDB = this.getCourseDB(item.courseID);
199
211
  const [cardData, tagsByCard] = await Promise.all([
200
212
  courseDB.getCourseDoc<CardData>(item.cardID),
201
213
  courseDB.getAppliedTagsBatch([item.cardID]),
202
214
  ]);
215
+ // const tFetch = performance.now(); // [perf] parked
203
216
 
204
217
  if (!isCourseElo(cardData.elo)) {
205
218
  cardData.elo = toCourseElo(cardData.elo);
@@ -214,6 +227,7 @@ export class CardHydrationService<TView = unknown> {
214
227
  })
215
228
  )
216
229
  );
230
+ // const tDocs = performance.now(); // [perf] parked
217
231
 
218
232
  // Extract audio URLs from all data fields and prefetch them
219
233
  const audioToPrefetch: string[] = [];
@@ -224,6 +238,7 @@ export class CardHydrationService<TView = unknown> {
224
238
  });
225
239
 
226
240
  // Dedupe and prefetch, waiting for browser cache to be ready
241
+ // const tAudioStart = performance.now(); // [perf] parked
227
242
  const uniqueAudioUrls = [...new Set(audioToPrefetch)];
228
243
  if (uniqueAudioUrls.length > 0) {
229
244
  logger.debug(
@@ -231,6 +246,7 @@ export class CardHydrationService<TView = unknown> {
231
246
  );
232
247
  await Promise.allSettled(uniqueAudioUrls.map(prefetchAudio));
233
248
  }
249
+ // const tAudio = performance.now(); // [perf] parked
234
250
 
235
251
  const data = dataDocs.map(displayableDataToViewData).reverse();
236
252
 
@@ -241,6 +257,14 @@ export class CardHydrationService<TView = unknown> {
241
257
  tags: tagsByCard.get(item.cardID) ?? [],
242
258
  });
243
259
 
260
+ // [perf] parked: per-card hydration timing (cardDoc+tags / dataDocs / audio)
261
+ // logger.info(
262
+ // `[perf][Hydrate] ${item.cardID}: ` +
263
+ // `cardDoc+tags=${(tFetch - tH0).toFixed(0)}ms ` +
264
+ // `dataDocs=${(tDocs - tFetch).toFixed(0)}ms ` +
265
+ // `audioPrefetch=${(tAudio - tAudioStart).toFixed(0)}ms(${uniqueAudioUrls.length} files) ` +
266
+ // `total=${(tAudio - tH0).toFixed(0)}ms`
267
+ // );
244
268
  logger.debug(`[CardHydrationService] Hydrated card ${item.cardID}`);
245
269
  } finally {
246
270
  this.hydrationInFlight.delete(item.cardID);