@vue-skuilder/db 0.1.38 → 0.1.39

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.38",
7
+ "version": "0.1.39",
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.38",
51
+ "@vue-skuilder/common": "0.1.39",
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.38"
65
+ "stableVersion": "0.1.39"
66
66
  }
@@ -171,14 +171,6 @@ export class SessionController<TView = unknown> extends Loggable {
171
171
  */
172
172
  private _suppressQualityReplan: boolean = false;
173
173
 
174
- /**
175
- * Guards against infinite depletion-triggered replans. Set to true
176
- * when a depletion replan fires; cleared when a replan produces
177
- * content (newQ.length > 0 after replan) or when an explicit
178
- * (non-auto) replan is requested.
179
- */
180
- private _depletionReplanAttempted: boolean = false;
181
-
182
174
  /**
183
175
  * When > 0, the session timer cannot end the session. Decremented on
184
176
  * each nextCard() draw. Set by replans that include `minFollowUpCards`.
@@ -370,12 +362,6 @@ export class SessionController<TView = unknown> extends Loggable {
370
362
  // Normalise: bare hints object (legacy callers) → ReplanOptions wrapper
371
363
  const opts = this.normalizeReplanOptions(options);
372
364
 
373
- // Explicit (non-auto) replans clear the depletion guard — the caller
374
- // is providing fresh intent that may change pipeline results.
375
- if (opts.hints || opts.label || opts.limit) {
376
- this._depletionReplanAttempted = false;
377
- }
378
-
379
365
  const hasIntent = this._replanHasIntent(opts);
380
366
 
381
367
  if (this._replanPromise) {
@@ -495,6 +481,26 @@ export class SessionController<TView = unknown> extends Loggable {
495
481
  await this._executeReplan(opts);
496
482
  }
497
483
 
484
+ /**
485
+ * Run a replan, bypassing requestReplan()'s coalesce logic.
486
+ *
487
+ * Use this when correctness depends on a *fresh* pipeline run, not on
488
+ * the existence of *some* in-flight replan. Specifically: the
489
+ * wedge-breaker path in nextCard(), where coalescing into a previous
490
+ * run that we now know produced insufficient content would re-create
491
+ * the bug we're trying to prevent.
492
+ *
493
+ * Still tracks _replanPromise like requestReplan() does so concurrent
494
+ * observers (auto-trigger guards in nextCard()) see consistent state.
495
+ */
496
+ private async _replanUncoalesced(opts: ReplanOptions): Promise<void> {
497
+ const run = this._runReplan(opts);
498
+ this._replanPromise = run.finally(() => {
499
+ if (this._replanPromise === run) this._replanPromise = null;
500
+ });
501
+ await run;
502
+ }
503
+
498
504
  /**
499
505
  * Normalise the requestReplan argument. Accepts either a ReplanOptions
500
506
  * object (new API) or a plain Record<string, unknown> (legacy callers
@@ -565,12 +571,6 @@ export class SessionController<TView = unknown> extends Loggable {
565
571
  );
566
572
  }
567
573
 
568
- // If the replan produced content, clear the depletion guard so future
569
- // depletions can trigger fresh replans.
570
- if (this.newQ.length > 0) {
571
- this._depletionReplanAttempted = false;
572
- }
573
-
574
574
  await this.hydrationService.ensureHydratedCards();
575
575
  const labelTag = opts.label ? ` [${opts.label}]` : '';
576
576
  this.log(`Replan complete${labelTag}: newQ now has ${this.newQ.length} cards (mode=${mode})`);
@@ -941,60 +941,45 @@ export class SessionController<TView = unknown> extends Loggable {
941
941
  await this._replanPromise;
942
942
  }
943
943
 
944
- // --- Auto-replan triggers ---
945
- // Two automatic replan triggers maintain queue freshness:
944
+ // --- Replan triggers ---
945
+ //
946
+ // Two flavors:
947
+ //
948
+ // (a) OPPORTUNISTIC PREFETCH (best-effort, may coalesce, may no-op):
949
+ // `depletion` and `quality` triggers below. Their job is to make
950
+ // replans happen *early*, so the user doesn't wait. They are
951
+ // *not* responsible for making replans happen *at all* — if every
952
+ // one of them gets eaten by coalescing or suppression, that's
953
+ // fine. They are perf optimizations.
946
954
  //
947
- // 1. Depletion: newQ is running dry → fire a replan so fresh content
948
- // is ready by the time the last card is consumed.
949
- // - newQ === 1: background replan overlaps pipeline latency with
950
- // the user's interaction on the last card. On the *next*
951
- // nextCard(), the _replanPromise await at the top ensures the
952
- // replan has landed before we try to draw.
953
- // - newQ === 0 && all queues empty: blocking await — nothing else
954
- // to serve, so we must wait for the pipeline before proceeding.
955
- // - newQ === 0 && other queues have content: background — the user
956
- // draws from reviewQ/failedQ while the pipeline runs.
955
+ // (b) LOAD-BEARING WEDGE-BREAKER (correctness):
956
+ // Below, right before the draw loop. Invariant: if the clock is
957
+ // ticking and we'd otherwise serve null, the pipeline runs. No
958
+ // coalesce, no latch, no flag. This is the *only* guarantee.
957
959
  //
958
- // 2. Quality: few well-indicated cards remain background replan so
959
- // the refreshed queue is ready by the time the buffer is consumed.
960
- // Suppressed after a burst replan to avoid clobbering burst cards.
960
+ // Rule of thumb: a redundant pipeline run is a perf bug, a missing
961
+ // pipeline run is a correctness bug. Bias toward the cheaper failure.
961
962
 
962
- // 1. Depletion trigger
963
- // Guarded by _depletionReplanAttempted to avoid infinite loops when
964
- // the pipeline consistently returns no new content.
963
+ // Opportunistic depletion: newQ running dry → background prefetch.
964
+ // No latch if this fires repeatedly when the pipeline keeps coming
965
+ // back empty, the wedge-breaker's local backoff handles spin protection.
965
966
  if (
966
967
  this.newQ.length <= 1 &&
967
968
  this._secondsRemaining > 0 &&
968
- !this._replanPromise &&
969
- !this._depletionReplanAttempted
969
+ !this._replanPromise
970
970
  ) {
971
- this._suppressQualityReplan = false; // burst is (nearly) consumed, clear suppression
972
- this._depletionReplanAttempted = true;
973
-
971
+ this._suppressQualityReplan = false; // burst is (nearly) consumed
974
972
  const otherContent = this.reviewQ.length + this.failedQ.length;
975
-
976
- if (this.newQ.length === 0 && otherContent === 0) {
977
- // Truly empty nothing to serve. Must block until the pipeline delivers.
978
- this.log(
979
- `[AutoReplan:depletion] All queues empty with ${this._secondsRemaining}s remaining. ` +
980
- `Awaiting replan.`
981
- );
982
- await this.requestReplan();
983
- } else {
984
- // Either 1 card remains (look-ahead) or other queues can cover.
985
- // Fire in background — pipeline runs while the user works.
986
- this.log(
987
- `[AutoReplan:depletion] newQ has ${this.newQ.length} card(s) ` +
988
- `(${otherContent} in other queues) with ${this._secondsRemaining}s remaining. ` +
989
- `Triggering background replan.`
990
- );
991
- void this.requestReplan();
992
- }
973
+ this.log(
974
+ `[AutoReplan:depletion] newQ has ${this.newQ.length} card(s) ` +
975
+ `(${otherContent} in other queues) with ${this._secondsRemaining}s remaining. ` +
976
+ `Triggering background replan.`
977
+ );
978
+ void this.requestReplan();
993
979
  }
994
980
 
995
- // 2. Quality trigger: few well-indicated cards remain. The buffer of
996
- // remaining good cards covers replan latency.
997
- // Suppressed after a burst replan to avoid clobbering burst cards.
981
+ // Opportunistic quality: few well-indicated cards remain.
982
+ // Suppressed after a burst replan to avoid clobbering burst cards.
998
983
  const REPLAN_BUFFER = 3;
999
984
  if (
1000
985
  !this._suppressQualityReplan &&
@@ -1015,6 +1000,49 @@ export class SessionController<TView = unknown> extends Loggable {
1015
1000
  return null;
1016
1001
  }
1017
1002
 
1003
+ // Wedge-breaker (correctness path).
1004
+ //
1005
+ // If we'd otherwise be about to draw from empty queues with time on
1006
+ // the clock, run the pipeline. Bypasses requestReplan() coalesce
1007
+ // because coalescing into a previous run that we now know produced
1008
+ // insufficient content is the exact failure mode this defends against.
1009
+ //
1010
+ // Bounded by an empty-streak counter: if the pipeline consistently
1011
+ // returns nothing, we eventually give up and let the session end
1012
+ // gracefully rather than spin forever.
1013
+ const WEDGE_MAX_EMPTY_STREAK = 3;
1014
+ const WEDGE_BACKOFF_MS = 250;
1015
+ let wedgeEmptyStreak = 0;
1016
+ while (
1017
+ this._secondsRemaining > 0 &&
1018
+ this.newQ.length === 0 &&
1019
+ this.reviewQ.length === 0 &&
1020
+ this.failedQ.length === 0
1021
+ ) {
1022
+ this.log(
1023
+ `[WedgeBreaker] All queues empty with ${this._secondsRemaining}s remaining. ` +
1024
+ `Running pipeline (attempt ${wedgeEmptyStreak + 1}/${WEDGE_MAX_EMPTY_STREAK}).`
1025
+ );
1026
+ await this._replanUncoalesced({ label: 'wedge-breaker' });
1027
+ if (
1028
+ this.newQ.length === 0 &&
1029
+ this.reviewQ.length === 0 &&
1030
+ this.failedQ.length === 0
1031
+ ) {
1032
+ wedgeEmptyStreak++;
1033
+ if (wedgeEmptyStreak >= WEDGE_MAX_EMPTY_STREAK) {
1034
+ this.log(
1035
+ `[WedgeBreaker] Pipeline returned no content ${WEDGE_MAX_EMPTY_STREAK} consecutive ` +
1036
+ `times. Giving up; session will end.`
1037
+ );
1038
+ break;
1039
+ }
1040
+ await new Promise((resolve) => setTimeout(resolve, WEDGE_BACKOFF_MS));
1041
+ } else {
1042
+ wedgeEmptyStreak = 0;
1043
+ }
1044
+ }
1045
+
1018
1046
  // Try multiple cards in case some fail hydration (e.g., deleted from DB)
1019
1047
  const MAX_SKIP = 20;
1020
1048
  for (let attempt = 0; attempt < MAX_SKIP; attempt++) {