@vue-skuilder/db 0.1.38 → 0.1.40

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.40",
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.40",
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.40"
66
66
  }
package/src/factory.ts CHANGED
@@ -37,6 +37,18 @@ export interface DataLayerConfig {
37
37
  COUCHDB_PASSWORD?: string;
38
38
 
39
39
  COURSE_IDS?: string[];
40
+
41
+ /**
42
+ * Per-app tuning for the CouchDB→PouchDB course sync. Only applies when
43
+ * `type === 'couch'` and the course has `localSync.enabled === true`.
44
+ * See CourseSyncService.ReplicationOptions for defaults.
45
+ */
46
+ courseSync?: {
47
+ replication?: {
48
+ batchSize?: number;
49
+ batchesLimit?: number;
50
+ };
51
+ };
40
52
  };
41
53
  }
42
54
 
@@ -69,6 +81,11 @@ export async function initializeDataLayer(config: DataLayerConfig): Promise<Data
69
81
  ENV.COUCHDB_USERNAME = config.options.COUCHDB_USERNAME;
70
82
  ENV.COUCHDB_PASSWORD = config.options.COUCHDB_PASSWORD;
71
83
 
84
+ if (config.options.courseSync) {
85
+ const { CourseSyncService } = await import('./impl/couch/CourseSyncService');
86
+ CourseSyncService.getInstance().configure(config.options.courseSync);
87
+ }
88
+
72
89
  if (
73
90
  config.options.COUCHDB_PASSWORD &&
74
91
  config.options.COUCHDB_USERNAME &&
@@ -82,10 +82,33 @@ interface SyncEntry {
82
82
  *
83
83
  * The service is a singleton — course sync state is shared across the app.
84
84
  */
85
+ /**
86
+ * Tuning knobs for one-shot remote→local replication.
87
+ * Defaults are chosen for one-shot snapshot replication of bounded course
88
+ * corpora (text/JSON docs, no large attachments). PouchDB's built-in
89
+ * defaults (100/10) target live continuous sync — too conservative for
90
+ * cold-load UX on a course of a few thousand docs.
91
+ *
92
+ * Apps with smaller/larger corpora can override via
93
+ * `initializeDataLayer({ options: { courseSync: { replication: {...} } } })`.
94
+ */
95
+ export interface ReplicationOptions {
96
+ /** Docs per `_bulk_get` HTTP request. Higher = fewer roundtrips. */
97
+ batchSize?: number;
98
+ /** Concurrent batches in flight. */
99
+ batchesLimit?: number;
100
+ }
101
+
102
+ const DEFAULT_REPLICATION: Required<ReplicationOptions> = {
103
+ batchSize: 1000,
104
+ batchesLimit: 5,
105
+ };
106
+
85
107
  export class CourseSyncService {
86
108
  private static instance: CourseSyncService | null = null;
87
109
 
88
110
  private entries: Map<string, SyncEntry> = new Map();
111
+ private replicationOptions: Required<ReplicationOptions> = DEFAULT_REPLICATION;
89
112
 
90
113
  private constructor() {}
91
114
 
@@ -96,6 +119,24 @@ export class CourseSyncService {
96
119
  return CourseSyncService.instance;
97
120
  }
98
121
 
122
+ /**
123
+ * Apply replication tuning. Typically called once from `initializeDataLayer`.
124
+ * Partial overrides merge with defaults.
125
+ */
126
+ configure(opts: { replication?: ReplicationOptions }): void {
127
+ if (opts.replication) {
128
+ this.replicationOptions = {
129
+ ...DEFAULT_REPLICATION,
130
+ ...opts.replication,
131
+ };
132
+ logger.info(
133
+ `[CourseSyncService] Replication configured: ` +
134
+ `batch_size=${this.replicationOptions.batchSize}, ` +
135
+ `batches_limit=${this.replicationOptions.batchesLimit}`
136
+ );
137
+ }
138
+ }
139
+
99
140
  /**
100
141
  * Reset the singleton (for testing).
101
142
  */
@@ -264,7 +305,9 @@ export class CourseSyncService {
264
305
  const syncStart = Date.now();
265
306
 
266
307
  logger.info(
267
- `[CourseSyncService] Starting one-shot replication for course ${courseId}`
308
+ `[CourseSyncService] Starting one-shot replication for course ${courseId} ` +
309
+ `(batch_size=${this.replicationOptions.batchSize}, ` +
310
+ `batches_limit=${this.replicationOptions.batchesLimit})`
268
311
  );
269
312
 
270
313
  const result = await this.replicate(remoteDB, localDB);
@@ -339,6 +382,8 @@ export class CourseSyncService {
339
382
  return new Promise((resolve, reject) => {
340
383
  void pouch.replicate(source, target, {
341
384
  // One-shot, not live. Local is a read-only snapshot.
385
+ batch_size: this.replicationOptions.batchSize,
386
+ batches_limit: this.replicationOptions.batchesLimit,
342
387
  })
343
388
  .on('complete', (info) => {
344
389
  resolve(info);
@@ -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})`);
@@ -933,68 +933,60 @@ export class SessionController<TView = unknown> extends Loggable {
933
933
  this.log(`[CardGuarantee] ${this._minCardsGuarantee} guaranteed cards remaining`);
934
934
  }
935
935
 
936
- // If a replan is in flight, wait for it to complete before drawing.
937
- // This ensures the user sees cards scored against their latest state
938
- // (e.g. after a GPC intro unlocked new content).
939
- if (this._replanPromise) {
940
- this.log('nextCard: awaiting in-flight replan before drawing');
936
+ // If a replan is in flight AND we have nothing queued to serve, wait for
937
+ // it. When queues still have cards, draw from them immediately and let
938
+ // the replan land asynchronously blocking here adds visible lag
939
+ // between cards for a marginal scoring-freshness benefit. The
940
+ // wedge-breaker below handles the genuinely-empty case.
941
+ if (
942
+ this._replanPromise &&
943
+ this.newQ.length === 0 &&
944
+ this.reviewQ.length === 0 &&
945
+ this.failedQ.length === 0
946
+ ) {
947
+ this.log('nextCard: queues empty, awaiting in-flight replan before drawing');
941
948
  await this._replanPromise;
942
949
  }
943
950
 
944
- // --- Auto-replan triggers ---
945
- // Two automatic replan triggers maintain queue freshness:
951
+ // --- Replan triggers ---
952
+ //
953
+ // Two flavors:
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
+ // (a) OPPORTUNISTIC PREFETCH (best-effort, may coalesce, may no-op):
956
+ // `depletion` and `quality` triggers below. Their job is to make
957
+ // replans happen *early*, so the user doesn't wait. They are
958
+ // *not* responsible for making replans happen *at all* if every
959
+ // one of them gets eaten by coalescing or suppression, that's
960
+ // fine. They are perf optimizations.
957
961
  //
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.
962
+ // (b) LOAD-BEARING WEDGE-BREAKER (correctness):
963
+ // Below, right before the draw loop. Invariant: if the clock is
964
+ // ticking and we'd otherwise serve null, the pipeline runs. No
965
+ // coalesce, no latch, no flag. This is the *only* guarantee.
966
+ //
967
+ // Rule of thumb: a redundant pipeline run is a perf bug, a missing
968
+ // pipeline run is a correctness bug. Bias toward the cheaper failure.
961
969
 
962
- // 1. Depletion trigger
963
- // Guarded by _depletionReplanAttempted to avoid infinite loops when
964
- // the pipeline consistently returns no new content.
970
+ // Opportunistic depletion: newQ running dry → background prefetch.
971
+ // No latch if this fires repeatedly when the pipeline keeps coming
972
+ // back empty, the wedge-breaker's local backoff handles spin protection.
965
973
  if (
966
974
  this.newQ.length <= 1 &&
967
975
  this._secondsRemaining > 0 &&
968
- !this._replanPromise &&
969
- !this._depletionReplanAttempted
976
+ !this._replanPromise
970
977
  ) {
971
- this._suppressQualityReplan = false; // burst is (nearly) consumed, clear suppression
972
- this._depletionReplanAttempted = true;
973
-
978
+ this._suppressQualityReplan = false; // burst is (nearly) consumed
974
979
  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
- }
980
+ this.log(
981
+ `[AutoReplan:depletion] newQ has ${this.newQ.length} card(s) ` +
982
+ `(${otherContent} in other queues) with ${this._secondsRemaining}s remaining. ` +
983
+ `Triggering background replan.`
984
+ );
985
+ void this.requestReplan();
993
986
  }
994
987
 
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.
988
+ // Opportunistic quality: few well-indicated cards remain.
989
+ // Suppressed after a burst replan to avoid clobbering burst cards.
998
990
  const REPLAN_BUFFER = 3;
999
991
  if (
1000
992
  !this._suppressQualityReplan &&
@@ -1015,6 +1007,49 @@ export class SessionController<TView = unknown> extends Loggable {
1015
1007
  return null;
1016
1008
  }
1017
1009
 
1010
+ // Wedge-breaker (correctness path).
1011
+ //
1012
+ // If we'd otherwise be about to draw from empty queues with time on
1013
+ // the clock, run the pipeline. Bypasses requestReplan() coalesce
1014
+ // because coalescing into a previous run that we now know produced
1015
+ // insufficient content is the exact failure mode this defends against.
1016
+ //
1017
+ // Bounded by an empty-streak counter: if the pipeline consistently
1018
+ // returns nothing, we eventually give up and let the session end
1019
+ // gracefully rather than spin forever.
1020
+ const WEDGE_MAX_EMPTY_STREAK = 3;
1021
+ const WEDGE_BACKOFF_MS = 250;
1022
+ let wedgeEmptyStreak = 0;
1023
+ while (
1024
+ this._secondsRemaining > 0 &&
1025
+ this.newQ.length === 0 &&
1026
+ this.reviewQ.length === 0 &&
1027
+ this.failedQ.length === 0
1028
+ ) {
1029
+ this.log(
1030
+ `[WedgeBreaker] All queues empty with ${this._secondsRemaining}s remaining. ` +
1031
+ `Running pipeline (attempt ${wedgeEmptyStreak + 1}/${WEDGE_MAX_EMPTY_STREAK}).`
1032
+ );
1033
+ await this._replanUncoalesced({ label: 'wedge-breaker' });
1034
+ if (
1035
+ this.newQ.length === 0 &&
1036
+ this.reviewQ.length === 0 &&
1037
+ this.failedQ.length === 0
1038
+ ) {
1039
+ wedgeEmptyStreak++;
1040
+ if (wedgeEmptyStreak >= WEDGE_MAX_EMPTY_STREAK) {
1041
+ this.log(
1042
+ `[WedgeBreaker] Pipeline returned no content ${WEDGE_MAX_EMPTY_STREAK} consecutive ` +
1043
+ `times. Giving up; session will end.`
1044
+ );
1045
+ break;
1046
+ }
1047
+ await new Promise((resolve) => setTimeout(resolve, WEDGE_BACKOFF_MS));
1048
+ } else {
1049
+ wedgeEmptyStreak = 0;
1050
+ }
1051
+ }
1052
+
1018
1053
  // Try multiple cards in case some fail hydration (e.g., deleted from DB)
1019
1054
  const MAX_SKIP = 20;
1020
1055
  for (let attempt = 0; attempt < MAX_SKIP; attempt++) {