@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/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs.map +1 -1
- package/dist/impl/couch/index.d.cts +25 -1
- package/dist/impl/couch/index.d.ts +25 -1
- package/dist/impl/couch/index.js +24 -2
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +24 -2
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +24 -7
- package/dist/index.d.ts +24 -7
- package/dist/index.js +79 -30
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +79 -30
- package/dist/index.mjs.map +1 -1
- package/package.json +3 -3
- package/src/factory.ts +17 -0
- package/src/impl/couch/CourseSyncService.ts +46 -1
- package/src/study/SessionController.ts +104 -69
package/package.json
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
"publishConfig": {
|
|
5
5
|
"access": "public"
|
|
6
6
|
},
|
|
7
|
-
"version": "0.1.
|
|
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.
|
|
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.
|
|
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
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
|
|
940
|
-
|
|
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
|
-
// ---
|
|
945
|
-
//
|
|
951
|
+
// --- Replan triggers ---
|
|
952
|
+
//
|
|
953
|
+
// Two flavors:
|
|
946
954
|
//
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
//
|
|
950
|
-
//
|
|
951
|
-
//
|
|
952
|
-
//
|
|
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
|
-
//
|
|
959
|
-
//
|
|
960
|
-
//
|
|
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
|
-
//
|
|
963
|
-
//
|
|
964
|
-
//
|
|
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
|
|
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
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
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
|
-
//
|
|
996
|
-
//
|
|
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++) {
|