@vue-skuilder/db 0.2.11 → 0.2.13

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.2.11",
7
+ "version": "0.2.13",
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.2.11",
51
+ "@vue-skuilder/common": "0.2.13",
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.2.11"
65
+ "stableVersion": "0.2.13"
66
66
  }
@@ -3,10 +3,11 @@
3
3
  // ============================================================================
4
4
  //
5
5
  // A tiny module-level capture of the SRS generator's per-run backlog state,
6
- // so the live session overlay can show whether reviews being out-competed by
7
- // (boosted) new cards is *temporary* (the backlog multiplier still has headroom
8
- // to climb and lift reviews) or *boost-driven* (the multiplier is maxed and the
9
- // new-card boosts simply sit higher only a hint relaxation reorders them).
6
+ // so the live session overlay can show how much runway remains before reviews
7
+ // out-compete the current (boosted) new/prescribed cards: the backlog
8
+ // multiplier is exponential and uncapped, so it always has headroom to climb
9
+ // further the question is just how large the backlog needs to get before it
10
+ // crosses whatever the competing boosts currently sit at.
10
11
  //
11
12
  // Mirrors MixerDebugger/PipelineDebugger: the generator pushes a snapshot each
12
13
  // run (keyed by course, latest wins); the overlay reads it via the controller's
@@ -23,10 +24,10 @@ export interface SrsBacklogDebug {
23
24
  dueNow: number;
24
25
  /** Healthy backlog threshold; multiplier is ×1.0 at or below this. */
25
26
  healthyBacklog: number;
26
- /** Global multiplier applied to every due review's urgency this run (1.0 → max). */
27
+ /** Global multiplier applied to every due review's urgency this run (>= 1.0, unbounded). */
27
28
  backlogMultiplier: number;
28
- /** Max achievable backlog multiplier (the cap), for headroom context. */
29
- maxBacklogMultiplier: number;
29
+ /** Exponential growth-rate base the multiplier climbs by per multiple of healthyBacklog excess. */
30
+ backlogGrowthRate: number;
30
31
  /** Highest review score produced this run (post-multiplier; can exceed 1.0); null if none due. */
31
32
  topReviewScore: number | null;
32
33
  /** Human-readable time until the next review comes due, or null if some are due now. */
@@ -42,19 +42,27 @@ import { logger } from '@db/util/logger';
42
42
  const DEFAULT_HEALTHY_BACKLOG = 20;
43
43
 
44
44
  /**
45
- * Maximum backlog pressure as a *multiplier* on review urgency.
45
+ * Growth-rate base for backlog pressure as a *multiplier* on review urgency.
46
46
  *
47
- * Backlog pressure is multiplicative (×1.0 at/below healthy, scaling up as the
48
- * due pile grows, maxing here at 3× healthy backlog). It replaces an older
49
- * additive +0..+0.5 term that was a [0,1]-era modifier once review scores
50
- * stopped being clamped to 1.0 and new cards could be boosted well past it
51
- * (e.g. an intro ×5 7+), a flat +0.5 was both too small to compete and mostly
52
- * eaten by the old 1.0 clamp. A multiplier scales review priority onto the same
53
- * open scale the boosted new cards live on, so a heavy backlog can genuinely
54
- * lift reviews into competition. Tunable verify review vs new ordering in the
55
- * dbg overlay's "review backpressure" panel.
47
+ * Backlog pressure is multiplicative (×1.0 at/below healthy) and exponential
48
+ * in the backlog's excess over healthy, expressed in multiples of
49
+ * `healthyBacklog` (see computeBacklogMultiplier)deliberately uncapped. It
50
+ * replaces an older additive +0..+0.5 term that was a [0,1]-era modifier
51
+ * once review scores stopped being clamped to 1.0 and new cards could be
52
+ * boosted well past it (e.g. an intro ×5 7+), a flat +0.5 was both too small
53
+ * to compete and mostly eaten by the old 1.0 clamp. A linear-then-capped
54
+ * multiplier came next, but that just moved the problem: other generators
55
+ * (e.g. Prescribed Intro Backpressure) score on a much larger open scale with
56
+ * their own, independently-tuned caps, so a hard review-side ceiling meant
57
+ * reviews could never win out no matter how backlogged they got. Exponential
58
+ * growth has no such ceiling — a sufficiently neglected backlog keeps
59
+ * climbing until it outcompetes anything, which is the intended long-term
60
+ * fallback: other generators can dominate short-term, but SRS is the
61
+ * framework-invariant backstop and should always win eventually. Tunable —
62
+ * verify review vs new ordering in the dbg overlay's "review backpressure"
63
+ * panel.
56
64
  */
57
- const MAX_BACKLOG_MULTIPLIER = 2.0;
65
+ const BACKLOG_GROWTH_RATE = 2;
58
66
 
59
67
  /**
60
68
  * Configuration for the SRS strategy.
@@ -249,7 +257,7 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
249
257
  dueNow: dueReviews.length,
250
258
  healthyBacklog: this.healthyBacklog,
251
259
  backlogMultiplier,
252
- maxBacklogMultiplier: MAX_BACKLOG_MULTIPLIER,
260
+ backlogGrowthRate: BACKLOG_GROWTH_RATE,
253
261
  topReviewScore: sorted.length > 0 ? sorted[0].score : null,
254
262
  nextDueIn,
255
263
  timestamp: Date.now(),
@@ -268,28 +276,32 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
268
276
  /**
269
277
  * Compute the multiplicative backlog pressure based on number of due reviews.
270
278
  *
271
- * ×1.0 at or below the healthy threshold (no boost), increasing linearly above
272
- * it and maxing out at MAX_BACKLOG_MULTIPLIER at the healthy backlog.
279
+ * ×1.0 at or below the healthy threshold (no boost); above it, grows as
280
+ * BACKLOG_GROWTH_RATE raised to the excess expressed in multiples of the
281
+ * healthy threshold. Uncapped — self-regulating instead: reviews winning
282
+ * slots depletes dueCount, which drops the ratio and relaxes the multiplier
283
+ * next run.
273
284
  *
274
- * Examples (with default healthyBacklog=20, MAX_BACKLOG_MULTIPLIER=2.0):
275
- * - 10 due reviews → ×1.00 (healthy)
276
- * - 20 due reviews → ×1.00 (at threshold)
277
- * - 40 due reviews → ×1.50 (2x threshold)
278
- * - 60 due reviews → ×2.00 (3x threshold, maxed)
285
+ * Examples (with default healthyBacklog=20, BACKLOG_GROWTH_RATE=1.5):
286
+ * - 10 due reviews → ×1.00 (healthy)
287
+ * - 20 due reviews → ×1.00 (at threshold, ratio 0)
288
+ * - 40 due reviews → ×1.50 (ratio 1, 2x threshold)
289
+ * - 60 due reviews → ×2.25 (ratio 2, 3x threshold — old hard cap was ×2.00 here)
290
+ * - 100 due reviews → ×5.06 (ratio 4, 5x threshold)
291
+ * - 160 due reviews → ×17.09 (ratio 7, 8x threshold)
279
292
  *
280
293
  * @param dueCount - Number of reviews currently due
281
- * @returns Multiplier applied to review urgency (1.0 to MAX_BACKLOG_MULTIPLIER)
294
+ * @returns Multiplier applied to review urgency (>= 1.0, unbounded)
282
295
  */
283
296
  private computeBacklogMultiplier(dueCount: number): number {
284
297
  if (dueCount <= this.healthyBacklog) {
285
298
  return 1.0;
286
299
  }
287
300
 
288
- // Linear in excess: ×1 at healthy, reaching MAX at 3× healthy (excess = 2×healthy).
289
301
  const excess = dueCount - this.healthyBacklog;
290
- const multiplier = 1 + (excess / this.healthyBacklog) * ((MAX_BACKLOG_MULTIPLIER - 1) / 2);
302
+ const ratio = excess / this.healthyBacklog;
291
303
 
292
- return Math.min(MAX_BACKLOG_MULTIPLIER, multiplier);
304
+ return Math.pow(BACKLOG_GROWTH_RATE, ratio);
293
305
  }
294
306
 
295
307
  /**
@@ -306,17 +318,17 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
306
318
  * - 180 days → ~0.30
307
319
  *
308
320
  * 3. Backlog pressure = global *multiplier* when review backlog exceeds the
309
- * healthy threshold (×1.0 healthy → up to MAX_BACKLOG_MULTIPLIER at ).
321
+ * healthy threshold (×1.0 healthy → exponential growth, uncapped, above it).
310
322
  *
311
323
  * Combined: (base 0.5 + urgency factors * 0.45) × backlog multiplier.
312
324
  * Per-card range before pressure: ~0.57–0.95. NOT clamped to 1.0 — under a
313
325
  * heavy backlog reviews scale onto the open scale to compete with (and exceed)
314
- * new cards; what keeps them from running away is the bounded multiplier, not
315
- * a hard ceiling.
326
+ * new cards; there's no ceiling at all now, so a bad enough backlog always
327
+ * wins eventually — self-regulating because winning slots depletes dueCount.
316
328
  *
317
329
  * @param review - The scheduled card to score
318
330
  * @param now - Current time
319
- * @param backlogMultiplier - Pre-computed backlog multiplier (1.0 to MAX_BACKLOG_MULTIPLIER)
331
+ * @param backlogMultiplier - Pre-computed backlog multiplier (>= 1.0, unbounded)
320
332
  */
321
333
  private computeUrgencyScore(
322
334
  review: ScheduledCard,
@@ -366,4 +378,4 @@ export default class SRSNavigator extends ContentNavigator implements CardGenera
366
378
 
367
379
  return { score, reason };
368
380
  }
369
- }
381
+ }
@@ -214,6 +214,19 @@ Currently logged-in as ${this._username}.`
214
214
  }
215
215
 
216
216
  const ret = await this.syncStrategy.logout!();
217
+
218
+ // Mint a FRESH guest identity on logout rather than re-adopting whatever
219
+ // sk-guest-uuid happens to survive in localStorage. A stale UUID can point
220
+ // at an already-consumed guest DB (one previously migrated into a real
221
+ // account); re-adopting it would resurface that account's progress and, on
222
+ // the next signup, migrate it into the new account. Clearing the pointer
223
+ // forces accomodateGuest() to generate a new UUID + empty guest DB.
224
+ try {
225
+ localStorage.removeItem('sk-guest-uuid');
226
+ } catch (e) {
227
+ logger.warn('localStorage not available (Node.js environment):', e);
228
+ }
229
+
217
230
  // return to 'guest' mode
218
231
  this._username = await this.syncStrategy.getCurrentUsername();
219
232
  await this.init();
@@ -228,6 +228,23 @@ export class CouchDBSyncStrategy implements SyncStrategy {
228
228
  logger.info('No documents to migrate from funnel account');
229
229
  }
230
230
 
231
+ // Destroy the consumed guest DB so it can never be re-migrated into a
232
+ // future account. Without this, an orphaned userdb-sk-guest-<uuid> lingers
233
+ // in IndexedDB indefinitely; if its stale identity is later re-adopted
234
+ // (e.g. on logout via a surviving sk-guest-uuid), its progress bleeds into
235
+ // the next new account. Best-effort: migration has already succeeded, so a
236
+ // teardown failure must not fail account creation.
237
+ try {
238
+ await oldLocalDB.destroy();
239
+ logger.info(`Destroyed consumed guest DB for ${oldUsername}`);
240
+ } catch (destroyErr) {
241
+ logger.warn(
242
+ `Failed to destroy consumed guest DB for ${oldUsername} (non-fatal): ${
243
+ destroyErr instanceof Error ? destroyErr.message : String(destroyErr)
244
+ }`
245
+ );
246
+ }
247
+
231
248
  return { success: true };
232
249
  } catch (error) {
233
250
  logger.error('Migration failed:', error);
@@ -20,7 +20,7 @@ import { ReplanHints } from '@db/core/navigators/generators/types';
20
20
  import { mergeHints } from '@db/core/navigators/Pipeline';
21
21
  import { SourceMixer, QuotaRoundRobinMixer, SourceBatch } from './SourceMixer';
22
22
  import { captureMixerRun } from './MixerDebugger';
23
- import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking } from './SessionDebugger';
23
+ import { startSessionTracking, recordCardPresentation, snapshotQueues, endSessionTracking, clearStaleSessionDebugState } from './SessionDebugger';
24
24
  import {
25
25
  registerActiveController,
26
26
  type SessionDebugSnapshot,
@@ -475,6 +475,10 @@ export class SessionController<TView = unknown> extends Loggable {
475
475
  );
476
476
  }
477
477
 
478
+ // Clear the previous session's debug state before this session's
479
+ // bootstrap pipeline run fires, so that run's own report isn't wiped out.
480
+ clearStaleSessionDebugState();
481
+
478
482
  const wellIndicated = await this.getWeightedContent();
479
483
  this._wellIndicatedRemaining = wellIndicated;
480
484
  if (wellIndicated >= 0 && wellIndicated < SessionController.MIN_WELL_INDICATED) {
@@ -68,6 +68,22 @@ let activeSession: SessionRunReport | null = null;
68
68
  const sessionHistory: SessionRunReport[] = [];
69
69
  const MAX_HISTORY = 5;
70
70
 
71
+ /**
72
+ * Release pipeline run-history / SRS backlog debug memory from the previous
73
+ * session before this session begins piling new runs on top of it. Must be
74
+ * called before the first pipeline run of the new session (including the
75
+ * bootstrap run in prepareSession) — clearing on session START rather than
76
+ * END keeps post-session inspection, the dominant debugging workflow, fully
77
+ * functional: a finished session's run history sits intact until the user
78
+ * actually begins another one. It's a separate call from startSessionTracking
79
+ * because that function can't run until supplyQ/failedQ are populated by the
80
+ * bootstrap pipeline run, which would otherwise be cleared out from under it.
81
+ */
82
+ export function clearStaleSessionDebugState(): void {
83
+ clearPipelineRunHistory();
84
+ clearSrsBacklogDebug();
85
+ }
86
+
71
87
  /**
72
88
  * Start tracking a new session.
73
89
  */
@@ -75,14 +91,6 @@ export function startSessionTracking(
75
91
  supplyQLength: number,
76
92
  failedQLength: number
77
93
  ): void {
78
- // Release pipeline run-history memory before this session begins piling
79
- // new runs on top of the previous session's retained reports. Clearing on
80
- // session START (rather than END) keeps post-session inspection — the
81
- // dominant debugging workflow — fully functional: a finished session's
82
- // run history sits intact until the user actually begins another one.
83
- clearPipelineRunHistory();
84
- clearSrsBacklogDebug();
85
-
86
94
  const sessionId = `session-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
87
95
 
88
96
  activeSession = {
@@ -365,25 +365,24 @@ function hintsHtml(h: ReplanHints | null): string {
365
365
 
366
366
  /**
367
367
  * SRS backlog panel — answers "is review starvation permanent?". Backlog
368
- * pressure is a multiplier (×1.0 healthy → max) on review urgency; reviews are
369
- * no longer clamped to 1.0, so under a heavy backlog they scale up to compete
370
- * with (and exceed) new cards. So:
371
- * - multiplier climbing, below max reviews will rise as the backlog grows
372
- * (temporary they'll start winning slots);
373
- * - multiplier maxed but still out-competed the boosts in `sessionHints`
374
- * simply sit higher; reviews only resurface once those relax (backoff), not
375
- * from more backlog. Compare `top review` here against the supplyQ head score.
368
+ * pressure is a multiplier (×1.0 healthy → exponential, uncapped) on review
369
+ * urgency; reviews are no longer clamped to 1.0, so under a heavy backlog they
370
+ * scale up to compete with (and exceed) new cards. There's no ceiling, so
371
+ * reviews always win eventually the panel shows how far the current
372
+ * multiplier is from that crossover, not whether it's "maxed": compare
373
+ * `top review` here against the supplyQ head score to see how much further
374
+ * the backlog needs to grow (or the boosts need to relax) before reviews win
375
+ * slots.
376
376
  */
377
377
  function backlogHtml(backlog: SrsBacklogDebug[]): string {
378
378
  if (!backlog.length) return '';
379
379
  const rows = backlog
380
380
  .map((b) => {
381
- const maxed = b.backlogMultiplier >= b.maxBacklogMultiplier - 1e-9;
382
- const multColor = b.backlogMultiplier <= 1 ? '#86efac' : maxed ? '#fca5a5' : '#fcd34d';
383
- const headroom = maxed
384
- ? 'maxed — boosts decide order until they relax'
385
- : b.backlogMultiplier > 1
386
- ? 'climbing as backlog grows'
381
+ const hot = b.backlogMultiplier >= 3;
382
+ const multColor = b.backlogMultiplier <= 1 ? '#86efac' : hot ? '#fca5a5' : '#fcd34d';
383
+ const headroom =
384
+ b.backlogMultiplier > 1
385
+ ? `climbing (×${b.backlogGrowthRate.toFixed(2)} per ${b.healthyBacklog}-review excess, unbounded)`
387
386
  : 'healthy — no pressure';
388
387
  const top = b.topReviewScore !== null ? b.topReviewScore.toFixed(2) : '—';
389
388
  const next = b.nextDueIn ? ` <span style="opacity:.6">· next due ${esc(b.nextDueIn)}</span>` : '';
@@ -392,7 +391,7 @@ function backlogHtml(backlog: SrsBacklogDebug[]): string {
392
391
  `<span style="opacity:.7">${esc(b.courseId.slice(0, 8))}</span> ` +
393
392
  `due ${b.dueNow}/${b.scheduledTotal} <span style="opacity:.6">(healthy ${b.healthyBacklog})</span>${next}` +
394
393
  `<div style="margin-left:6px">` +
395
- `pressure <span style="color:${multColor}">×${b.backlogMultiplier.toFixed(2)}/${b.maxBacklogMultiplier.toFixed(2)}</span> ` +
394
+ `pressure <span style="color:${multColor}">×${b.backlogMultiplier.toFixed(2)}</span> ` +
396
395
  `<span style="opacity:.6">${headroom} · top review ${top}</span></div>` +
397
396
  `</div>`
398
397
  );
@@ -541,18 +540,13 @@ function snapshotToText(s: SessionDebugSnapshot | null): string {
541
540
  lines.push('');
542
541
  lines.push('review backpressure:');
543
542
  for (const b of s.reviewBacklog) {
544
- const maxed = b.backlogMultiplier >= b.maxBacklogMultiplier - 1e-9;
545
- const headroom = maxed
546
- ? 'maxed (boosts decide order)'
547
- : b.backlogMultiplier > 1
548
- ? 'climbing'
549
- : 'healthy';
543
+ const headroom = b.backlogMultiplier > 1 ? 'climbing' : 'healthy';
550
544
  const top = b.topReviewScore !== null ? b.topReviewScore.toFixed(2) : '—';
551
545
  const next = b.nextDueIn ? `, next due ${b.nextDueIn}` : '';
552
546
  lines.push(
553
547
  ` ${b.courseId.slice(0, 8)}: due ${b.dueNow}/${b.scheduledTotal} ` +
554
- `(healthy ${b.healthyBacklog})${next}; pressure ×${b.backlogMultiplier.toFixed(2)}/` +
555
- `${b.maxBacklogMultiplier.toFixed(2)} ${headroom}; top review ${top}`
548
+ `(healthy ${b.healthyBacklog})${next}; pressure ×${b.backlogMultiplier.toFixed(2)} ` +
549
+ `${headroom}; top review ${top}`
556
550
  );
557
551
  }
558
552
  }