@vue-skuilder/db 0.2.16 → 0.2.18
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/{SyncStrategy-CyATpyLQ.d.cts → SyncStrategy-BJMZq3WO.d.cts} +17 -0
- package/dist/{SyncStrategy-CyATpyLQ.d.ts → SyncStrategy-BJMZq3WO.d.ts} +17 -0
- package/dist/{contentSource-Brz42x7n.d.cts → contentSource-CBqZCoGU.d.cts} +85 -1
- package/dist/{contentSource-B1p-vdz7.d.ts → contentSource-CudEz5Tm.d.ts} +85 -1
- package/dist/core/index.d.cts +51 -4
- package/dist/core/index.d.ts +51 -4
- package/dist/core/index.js +258 -7
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +254 -7
- package/dist/core/index.mjs.map +1 -1
- package/dist/{dataLayerProvider-CpwpT1rM.d.cts → dataLayerProvider-BBA8tJNx.d.cts} +1 -1
- package/dist/{dataLayerProvider-BWayUIoK.d.ts → dataLayerProvider-C9WgkBzR.d.ts} +1 -1
- package/dist/impl/couch/index.d.cts +16 -3
- package/dist/impl/couch/index.d.ts +16 -3
- package/dist/impl/couch/index.js +284 -9
- package/dist/impl/couch/index.js.map +1 -1
- package/dist/impl/couch/index.mjs +284 -9
- package/dist/impl/couch/index.mjs.map +1 -1
- package/dist/impl/static/index.d.cts +3 -3
- package/dist/impl/static/index.d.ts +3 -3
- package/dist/impl/static/index.js +250 -7
- package/dist/impl/static/index.js.map +1 -1
- package/dist/impl/static/index.mjs +250 -7
- package/dist/impl/static/index.mjs.map +1 -1
- package/dist/index.d.cts +8 -6
- package/dist/index.d.ts +8 -6
- package/dist/index.js +354 -10
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +350 -10
- package/dist/index.mjs.map +1 -1
- package/package.json +4 -3
- package/src/core/index.ts +1 -0
- package/src/core/interfaces/userDB.ts +11 -0
- package/src/core/navigators/StrategyPressureDebugger.ts +76 -0
- package/src/core/navigators/generators/prescribed.ts +122 -7
- package/src/core/navigators/index.ts +12 -0
- package/src/core/types/hydration.ts +78 -0
- package/src/impl/common/BaseUserDB.ts +202 -2
- package/src/impl/common/SyncStrategy.ts +19 -0
- package/src/impl/couch/CouchDBSyncStrategy.ts +54 -4
- package/src/study/SessionController.ts +7 -1
- package/src/study/SessionDebugger.ts +2 -0
- package/src/study/SessionOverlay.ts +113 -0
- package/tests/impl/hydration.test.ts +281 -0
|
@@ -1566,6 +1566,30 @@ var init_SrsDebugger = __esm({
|
|
|
1566
1566
|
}
|
|
1567
1567
|
});
|
|
1568
1568
|
|
|
1569
|
+
// src/core/navigators/StrategyPressureDebugger.ts
|
|
1570
|
+
var StrategyPressureDebugger_exports = {};
|
|
1571
|
+
__export(StrategyPressureDebugger_exports, {
|
|
1572
|
+
captureStrategyPressure: () => captureStrategyPressure,
|
|
1573
|
+
clearStrategyPressureDebug: () => clearStrategyPressureDebug,
|
|
1574
|
+
getStrategyPressureDebug: () => getStrategyPressureDebug
|
|
1575
|
+
});
|
|
1576
|
+
function captureStrategyPressure(snapshot) {
|
|
1577
|
+
snapshots2.set(`${snapshot.source}:${snapshot.courseId}`, snapshot);
|
|
1578
|
+
}
|
|
1579
|
+
function getStrategyPressureDebug() {
|
|
1580
|
+
return [...snapshots2.values()].sort((a, b) => b.timestamp - a.timestamp);
|
|
1581
|
+
}
|
|
1582
|
+
function clearStrategyPressureDebug() {
|
|
1583
|
+
snapshots2.clear();
|
|
1584
|
+
}
|
|
1585
|
+
var snapshots2;
|
|
1586
|
+
var init_StrategyPressureDebugger = __esm({
|
|
1587
|
+
"src/core/navigators/StrategyPressureDebugger.ts"() {
|
|
1588
|
+
"use strict";
|
|
1589
|
+
snapshots2 = /* @__PURE__ */ new Map();
|
|
1590
|
+
}
|
|
1591
|
+
});
|
|
1592
|
+
|
|
1569
1593
|
// src/core/navigators/generators/CompositeGenerator.ts
|
|
1570
1594
|
var CompositeGenerator_exports = {};
|
|
1571
1595
|
__export(CompositeGenerator_exports, {
|
|
@@ -1930,6 +1954,13 @@ function shuffleInPlace(arr) {
|
|
|
1930
1954
|
[arr[i], arr[j]] = [arr[j], arr[i]];
|
|
1931
1955
|
}
|
|
1932
1956
|
}
|
|
1957
|
+
function formatAge(ms) {
|
|
1958
|
+
const minutes = Math.max(0, Math.round(ms / 6e4));
|
|
1959
|
+
if (minutes < 60) return `${minutes}m`;
|
|
1960
|
+
const hours = Math.round(minutes / 60);
|
|
1961
|
+
if (hours < 24) return `${hours}h`;
|
|
1962
|
+
return `${Math.round(hours / 24)}d`;
|
|
1963
|
+
}
|
|
1933
1964
|
function pickTopByScore(cards, limit) {
|
|
1934
1965
|
return [...cards].sort((a, b) => b.score - a.score || a.cardId.localeCompare(b.cardId)).slice(0, limit);
|
|
1935
1966
|
}
|
|
@@ -1938,6 +1969,7 @@ var init_prescribed = __esm({
|
|
|
1938
1969
|
"src/core/navigators/generators/prescribed.ts"() {
|
|
1939
1970
|
"use strict";
|
|
1940
1971
|
init_navigators();
|
|
1972
|
+
init_StrategyPressureDebugger();
|
|
1941
1973
|
init_logger();
|
|
1942
1974
|
DEFAULT_FRESHNESS_WINDOW = 3;
|
|
1943
1975
|
DEFAULT_MAX_DIRECT_PER_RUN = 3;
|
|
@@ -2014,6 +2046,7 @@ var init_prescribed = __esm({
|
|
|
2014
2046
|
const groupRuntimes = [];
|
|
2015
2047
|
const priorPracticeDebt = progress.practiceDebt ?? {};
|
|
2016
2048
|
const nextPracticeDebt = {};
|
|
2049
|
+
const practiceDebtsByGroup = /* @__PURE__ */ new Map();
|
|
2017
2050
|
for (const group of this.config.groups) {
|
|
2018
2051
|
const runtime = this.buildGroupRuntimeState({
|
|
2019
2052
|
group,
|
|
@@ -2062,7 +2095,7 @@ var init_prescribed = __esm({
|
|
|
2062
2095
|
courseId,
|
|
2063
2096
|
emittedIds
|
|
2064
2097
|
);
|
|
2065
|
-
const
|
|
2098
|
+
const practice = this.buildPracticeCards({
|
|
2066
2099
|
group,
|
|
2067
2100
|
courseId,
|
|
2068
2101
|
emittedIds,
|
|
@@ -2075,7 +2108,8 @@ var init_prescribed = __esm({
|
|
|
2075
2108
|
priorPracticeDebt,
|
|
2076
2109
|
nextPracticeDebt
|
|
2077
2110
|
});
|
|
2078
|
-
|
|
2111
|
+
practiceDebtsByGroup.set(group.id, practice.debts);
|
|
2112
|
+
emitted.push(...directCards, ...supportCards, ...discoveredSupportCards, ...practice.cards);
|
|
2079
2113
|
}
|
|
2080
2114
|
nextState.practiceDebt = nextPracticeDebt;
|
|
2081
2115
|
const hintSummary = this.buildSupportHintSummary(groupRuntimes);
|
|
@@ -2091,6 +2125,7 @@ var init_prescribed = __esm({
|
|
|
2091
2125
|
} else {
|
|
2092
2126
|
logger.info("[Prescribed] No hints to emit (no blocked targets or no support tags)");
|
|
2093
2127
|
}
|
|
2128
|
+
this.capturePressureSnapshot(courseId, groupRuntimes, practiceDebtsByGroup, emitted, hints);
|
|
2094
2129
|
if (emitted.length === 0) {
|
|
2095
2130
|
logger.info(
|
|
2096
2131
|
"[Prescribed] 0 cards emitted (all targets blocked, authored/discovered support candidates exhausted)" + (hints ? " \u2014 boost hints emitted but may not survive filters" : "")
|
|
@@ -2152,6 +2187,60 @@ var init_prescribed = __esm({
|
|
|
2152
2187
|
supportTags: [...supportTags].sort()
|
|
2153
2188
|
};
|
|
2154
2189
|
}
|
|
2190
|
+
/**
|
|
2191
|
+
* Translate this run's per-group runtimes and practice debts into gauges on
|
|
2192
|
+
* the generic strategy-pressure debug channel (rendered by the live session
|
|
2193
|
+
* overlay's "strategy backpressure" section).
|
|
2194
|
+
*/
|
|
2195
|
+
capturePressureSnapshot(courseId, groupRuntimes, practiceDebtsByGroup, emitted, hints) {
|
|
2196
|
+
const now = Date.now();
|
|
2197
|
+
const gauges = [];
|
|
2198
|
+
for (const runtime of groupRuntimes) {
|
|
2199
|
+
const group = runtime.group;
|
|
2200
|
+
const window2 = group.freshnessWindowSessions ?? DEFAULT_FRESHNESS_WINDOW;
|
|
2201
|
+
gauges.push({
|
|
2202
|
+
id: `group:${group.id}:target`,
|
|
2203
|
+
label: `${group.id} targets`,
|
|
2204
|
+
multiplier: runtime.pressureMultiplier,
|
|
2205
|
+
max: MAX_TARGET_MULTIPLIER,
|
|
2206
|
+
detail: `${runtime.pendingTargets.length} pending (${runtime.surfaceableTargets.length} surfaceable, ${runtime.blockedTargets.length} blocked) \xB7 sinceSurfaced ${runtime.sessionsSinceSurfaced}/${window2}`,
|
|
2207
|
+
items: runtime.blockedTargets.map((cardId) => ({ label: cardId, value: "blocked" }))
|
|
2208
|
+
});
|
|
2209
|
+
if (runtime.blockedTargets.length > 0) {
|
|
2210
|
+
const mode = runtime.supportCandidates.length > 0 ? "direct-support" : runtime.discoveredSupportCandidates.length > 0 ? "inserted-support" : "boost-only";
|
|
2211
|
+
gauges.push({
|
|
2212
|
+
id: `group:${group.id}:support`,
|
|
2213
|
+
label: `${group.id} support`,
|
|
2214
|
+
multiplier: runtime.supportMultiplier,
|
|
2215
|
+
max: MAX_SUPPORT_MULTIPLIER,
|
|
2216
|
+
detail: `mode=${mode} \xB7 ${runtime.supportTags.length} support tag(s)`,
|
|
2217
|
+
items: runtime.supportTags.map((tag) => ({ label: tag }))
|
|
2218
|
+
});
|
|
2219
|
+
}
|
|
2220
|
+
const debts = practiceDebtsByGroup.get(group.id) ?? [];
|
|
2221
|
+
if (debts.length > 0) {
|
|
2222
|
+
gauges.push({
|
|
2223
|
+
id: `group:${group.id}:practice-debt`,
|
|
2224
|
+
label: `${group.id} practice debt`,
|
|
2225
|
+
multiplier: Math.max(...debts.map((d) => d.multiplier)),
|
|
2226
|
+
max: MAX_PRACTICE_MULTIPLIER,
|
|
2227
|
+
detail: `${debts.length} under-practiced skill(s)`,
|
|
2228
|
+
items: debts.map((d) => ({
|
|
2229
|
+
label: d.tag,
|
|
2230
|
+
value: `\xD7${d.multiplier.toFixed(2)} \xB7 open ${formatAge(now - new Date(d.firstOwedAt).getTime())}`
|
|
2231
|
+
}))
|
|
2232
|
+
});
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
captureStrategyPressure({
|
|
2236
|
+
source: "prescribed",
|
|
2237
|
+
courseId,
|
|
2238
|
+
gauges,
|
|
2239
|
+
topScore: emitted.length > 0 ? Math.max(...emitted.map((c) => c.score)) : null,
|
|
2240
|
+
hintsLabel: hints?._label,
|
|
2241
|
+
timestamp: now
|
|
2242
|
+
});
|
|
2243
|
+
}
|
|
2155
2244
|
parseConfig(serializedData) {
|
|
2156
2245
|
try {
|
|
2157
2246
|
const parsed = JSON.parse(serializedData);
|
|
@@ -2308,6 +2397,7 @@ var init_prescribed = __esm({
|
|
|
2308
2397
|
supportTags: [...supportTags],
|
|
2309
2398
|
pressureMultiplier,
|
|
2310
2399
|
supportMultiplier,
|
|
2400
|
+
sessionsSinceSurfaced,
|
|
2311
2401
|
debugVersion: PRESCRIBED_DEBUG_VERSION
|
|
2312
2402
|
};
|
|
2313
2403
|
}
|
|
@@ -2440,13 +2530,13 @@ var init_prescribed = __esm({
|
|
|
2440
2530
|
nextPracticeDebt
|
|
2441
2531
|
} = args;
|
|
2442
2532
|
const patterns = group.practiceTagPatterns ?? [];
|
|
2443
|
-
if (patterns.length === 0) return [];
|
|
2533
|
+
if (patterns.length === 0) return { cards: [], debts: [] };
|
|
2444
2534
|
const practiceMinCount = group.practiceMinCount ?? DEFAULT_PRACTICE_MIN_COUNT;
|
|
2445
2535
|
const maxPractice = group.maxPracticeCardsPerRun ?? DEFAULT_MAX_PRACTICE_PER_RUN;
|
|
2446
2536
|
const practiceTags = [...cardsByTag.keys()].filter(
|
|
2447
2537
|
(tag) => patterns.some((p) => matchesTagPattern(tag, p)) && this.isUnlockedGatedSkill(tag, hierarchyConfigs, userTagElo, userGlobalElo) && (userTagElo[tag]?.count ?? 0) < practiceMinCount
|
|
2448
2538
|
);
|
|
2449
|
-
if (practiceTags.length === 0) return [];
|
|
2539
|
+
if (practiceTags.length === 0) return { cards: [], debts: [] };
|
|
2450
2540
|
const now = Date.now();
|
|
2451
2541
|
const DAY_MS = 24 * 60 * 60 * 1e3;
|
|
2452
2542
|
const tagMultiplier = /* @__PURE__ */ new Map();
|
|
@@ -2461,6 +2551,11 @@ var init_prescribed = __esm({
|
|
|
2461
2551
|
);
|
|
2462
2552
|
tagMultiplier.set(tag, mult);
|
|
2463
2553
|
}
|
|
2554
|
+
const debts = practiceTags.map((tag) => ({
|
|
2555
|
+
tag,
|
|
2556
|
+
multiplier: tagMultiplier.get(tag) ?? PRACTICE_BASE_MULT,
|
|
2557
|
+
firstOwedAt: nextPracticeDebt[tag]
|
|
2558
|
+
})).sort((a, b) => b.multiplier - a.multiplier || a.tag.localeCompare(b.tag));
|
|
2464
2559
|
const practiceCardIds = this.findDiscoveredSupportCards({
|
|
2465
2560
|
supportTags: practiceTags,
|
|
2466
2561
|
cardsByTag,
|
|
@@ -2469,7 +2564,7 @@ var init_prescribed = __esm({
|
|
|
2469
2564
|
excludedIds: emittedIds,
|
|
2470
2565
|
limit: maxPractice
|
|
2471
2566
|
});
|
|
2472
|
-
if (practiceCardIds.length === 0) return [];
|
|
2567
|
+
if (practiceCardIds.length === 0) return { cards: [], debts };
|
|
2473
2568
|
logger.info(
|
|
2474
2569
|
`[Prescribed] Group '${group.id}' practice: ${practiceTags.length} unlocked under-practiced skill(s), emitting ${practiceCardIds.length} drill card(s)`
|
|
2475
2570
|
);
|
|
@@ -2499,7 +2594,7 @@ var init_prescribed = __esm({
|
|
|
2499
2594
|
]
|
|
2500
2595
|
});
|
|
2501
2596
|
}
|
|
2502
|
-
return cards;
|
|
2597
|
+
return { cards, debts };
|
|
2503
2598
|
}
|
|
2504
2599
|
/**
|
|
2505
2600
|
* True for a skill that was *gated and is now reached*: it has at least one
|
|
@@ -4930,6 +5025,7 @@ var init_3 = __esm({
|
|
|
4930
5025
|
"./PipelineAssembler.ts": () => Promise.resolve().then(() => (init_PipelineAssembler(), PipelineAssembler_exports)),
|
|
4931
5026
|
"./PipelineDebugger.ts": () => Promise.resolve().then(() => (init_PipelineDebugger(), PipelineDebugger_exports)),
|
|
4932
5027
|
"./SrsDebugger.ts": () => Promise.resolve().then(() => (init_SrsDebugger(), SrsDebugger_exports)),
|
|
5028
|
+
"./StrategyPressureDebugger.ts": () => Promise.resolve().then(() => (init_StrategyPressureDebugger(), StrategyPressureDebugger_exports)),
|
|
4933
5029
|
"./defaults.ts": () => Promise.resolve().then(() => (init_defaults(), defaults_exports)),
|
|
4934
5030
|
"./diversityRerank.ts": () => Promise.resolve().then(() => (init_diversityRerank(), diversityRerank_exports)),
|
|
4935
5031
|
"./filters/WeightedFilter.ts": () => Promise.resolve().then(() => (init_WeightedFilter(), WeightedFilter_exports)),
|
|
@@ -4962,7 +5058,9 @@ __export(navigators_exports, {
|
|
|
4962
5058
|
NavigatorRole: () => NavigatorRole,
|
|
4963
5059
|
NavigatorRoles: () => NavigatorRoles,
|
|
4964
5060
|
Navigators: () => Navigators,
|
|
5061
|
+
captureStrategyPressure: () => captureStrategyPressure,
|
|
4965
5062
|
clearSrsBacklogDebug: () => clearSrsBacklogDebug,
|
|
5063
|
+
clearStrategyPressureDebug: () => clearStrategyPressureDebug,
|
|
4966
5064
|
diversityRerank: () => diversityRerank,
|
|
4967
5065
|
getActivePipeline: () => getActivePipeline,
|
|
4968
5066
|
getCardOrigin: () => getCardOrigin,
|
|
@@ -4970,6 +5068,7 @@ __export(navigators_exports, {
|
|
|
4970
5068
|
getRegisteredNavigatorNames: () => getRegisteredNavigatorNames,
|
|
4971
5069
|
getRegisteredNavigatorRole: () => getRegisteredNavigatorRole,
|
|
4972
5070
|
getSrsBacklogDebug: () => getSrsBacklogDebug,
|
|
5071
|
+
getStrategyPressureDebug: () => getStrategyPressureDebug,
|
|
4973
5072
|
hasRegisteredNavigator: () => hasRegisteredNavigator,
|
|
4974
5073
|
initializeNavigatorRegistry: () => initializeNavigatorRegistry,
|
|
4975
5074
|
isFilter: () => isFilter,
|
|
@@ -5052,6 +5151,7 @@ var init_navigators = __esm({
|
|
|
5052
5151
|
init_diversityRerank();
|
|
5053
5152
|
init_PipelineDebugger();
|
|
5054
5153
|
init_SrsDebugger();
|
|
5154
|
+
init_StrategyPressureDebugger();
|
|
5055
5155
|
init_logger();
|
|
5056
5156
|
init_();
|
|
5057
5157
|
init_2();
|
|
@@ -6630,6 +6730,15 @@ var init_userOutcome = __esm({
|
|
|
6630
6730
|
}
|
|
6631
6731
|
});
|
|
6632
6732
|
|
|
6733
|
+
// src/core/types/hydration.ts
|
|
6734
|
+
var HYDRATION_MARKER_ID;
|
|
6735
|
+
var init_hydration = __esm({
|
|
6736
|
+
"src/core/types/hydration.ts"() {
|
|
6737
|
+
"use strict";
|
|
6738
|
+
HYDRATION_MARKER_ID = "_local/hydration";
|
|
6739
|
+
}
|
|
6740
|
+
});
|
|
6741
|
+
|
|
6633
6742
|
// src/core/util/index.ts
|
|
6634
6743
|
function getCardHistoryID(courseID, cardID) {
|
|
6635
6744
|
return `${DocTypePrefixes["CARDRECORD" /* CARDRECORD */]}-${courseID}-${cardID}`;
|
|
@@ -7125,6 +7234,7 @@ var init_core = __esm({
|
|
|
7125
7234
|
init_user();
|
|
7126
7235
|
init_strategyState();
|
|
7127
7236
|
init_userOutcome();
|
|
7237
|
+
init_hydration();
|
|
7128
7238
|
init_Loggable();
|
|
7129
7239
|
init_util();
|
|
7130
7240
|
init_navigators();
|
|
@@ -7200,6 +7310,19 @@ var init_user_course_relDB = __esm({
|
|
|
7200
7310
|
// src/impl/common/BaseUserDB.ts
|
|
7201
7311
|
import { Status as Status3 } from "@vue-skuilder/common";
|
|
7202
7312
|
import moment5 from "moment";
|
|
7313
|
+
function withTimeout(p, ms, label) {
|
|
7314
|
+
let timer;
|
|
7315
|
+
return Promise.race([
|
|
7316
|
+
p,
|
|
7317
|
+
new Promise((_resolve, reject) => {
|
|
7318
|
+
timer = setTimeout(() => reject(new Error(`${label} timed out after ${ms}ms`)), ms);
|
|
7319
|
+
})
|
|
7320
|
+
]).finally(() => {
|
|
7321
|
+
if (timer !== void 0) {
|
|
7322
|
+
clearTimeout(timer);
|
|
7323
|
+
}
|
|
7324
|
+
});
|
|
7325
|
+
}
|
|
7203
7326
|
function accomodateGuest() {
|
|
7204
7327
|
logger.log("[funnel] accomodateGuest() called");
|
|
7205
7328
|
if (typeof localStorage === "undefined") {
|
|
@@ -7373,7 +7496,7 @@ async function dropUserFromClassroom(user, classID) {
|
|
|
7373
7496
|
async function getUserClassrooms(user) {
|
|
7374
7497
|
return getOrCreateClassroomRegistrationsDoc(user);
|
|
7375
7498
|
}
|
|
7376
|
-
var log3, BaseUser, userCoursesDoc, userClassroomsDoc;
|
|
7499
|
+
var log3, HYDRATION_TIMEOUT_MS, BaseUser, userCoursesDoc, userClassroomsDoc;
|
|
7377
7500
|
var init_BaseUserDB = __esm({
|
|
7378
7501
|
"src/impl/common/BaseUserDB.ts"() {
|
|
7379
7502
|
"use strict";
|
|
@@ -7388,6 +7511,7 @@ var init_BaseUserDB = __esm({
|
|
|
7388
7511
|
log3 = (s) => {
|
|
7389
7512
|
logger.info(s);
|
|
7390
7513
|
};
|
|
7514
|
+
HYDRATION_TIMEOUT_MS = 15e3;
|
|
7391
7515
|
BaseUser = class _BaseUser {
|
|
7392
7516
|
static _instance;
|
|
7393
7517
|
static _initialized = false;
|
|
@@ -7416,6 +7540,29 @@ var init_BaseUserDB = __esm({
|
|
|
7416
7540
|
writeDB;
|
|
7417
7541
|
// Database to use for write operations (local-first approach)
|
|
7418
7542
|
updateQueue;
|
|
7543
|
+
_hydration = { state: "not-required" };
|
|
7544
|
+
/**
|
|
7545
|
+
* How far the local mirror can be trusted. See {@link UserHydrationStatus}.
|
|
7546
|
+
*
|
|
7547
|
+
* Consumers that would otherwise read a 404 as "new user" — onboarding
|
|
7548
|
+
* gates, first-run experiences, progress dashboards — should check for
|
|
7549
|
+
* `failed` and present a retry affordance rather than an empty state.
|
|
7550
|
+
*/
|
|
7551
|
+
hydrationStatus() {
|
|
7552
|
+
return { ...this._hydration };
|
|
7553
|
+
}
|
|
7554
|
+
/**
|
|
7555
|
+
* Whether a missing document may be treated as genuinely absent, allowing
|
|
7556
|
+
* callers to create it with defaults.
|
|
7557
|
+
*
|
|
7558
|
+
* False only when hydration failed or is still running: a 404 then may just
|
|
7559
|
+
* mean "not pulled down yet", and materializing a default would both lie to
|
|
7560
|
+
* the user and, once live sync starts, push a rev-1 document that loses to
|
|
7561
|
+
* the real one — silently discarding it.
|
|
7562
|
+
*/
|
|
7563
|
+
canMaterializeDefaults() {
|
|
7564
|
+
return this._hydration.state !== "failed" && this._hydration.state !== "hydrating";
|
|
7565
|
+
}
|
|
7419
7566
|
async createAccount(username, password) {
|
|
7420
7567
|
if (!this.syncStrategy.canCreateAccount()) {
|
|
7421
7568
|
throw new Error("Account creation not supported by current sync strategy");
|
|
@@ -7533,6 +7680,11 @@ Currently logged-in as ${this._username}.`
|
|
|
7533
7680
|
} catch (e) {
|
|
7534
7681
|
const err = e;
|
|
7535
7682
|
if (err.status === 404) {
|
|
7683
|
+
if (!this.canMaterializeDefaults()) {
|
|
7684
|
+
throw new Error(
|
|
7685
|
+
`Cannot read course registrations for ${this._username}: the local mirror is unavailable (hydration state '${this._hydration.state}'). Refusing to create an empty registration doc \u2014 that would report an existing user as unregistered, and then lose to their real document once sync resumes.`
|
|
7686
|
+
);
|
|
7687
|
+
}
|
|
7536
7688
|
await this.localDB.put({
|
|
7537
7689
|
_id: _BaseUser.DOC_IDS.COURSE_REGISTRATIONS,
|
|
7538
7690
|
courses: [],
|
|
@@ -7775,6 +7927,11 @@ Currently logged-in as ${this._username}.`
|
|
|
7775
7927
|
} catch (e) {
|
|
7776
7928
|
const err = e;
|
|
7777
7929
|
if (err.name && err.name === "not_found") {
|
|
7930
|
+
if (!this.canMaterializeDefaults()) {
|
|
7931
|
+
throw new Error(
|
|
7932
|
+
`Cannot read config for ${this._username}: the local mirror is unavailable (hydration state '${this._hydration.state}'). Refusing to write a default config over what may be an existing one.`
|
|
7933
|
+
);
|
|
7934
|
+
}
|
|
7778
7935
|
await this.localDB.put(defaultConfig);
|
|
7779
7936
|
return this.getConfig();
|
|
7780
7937
|
} else {
|
|
@@ -7848,7 +8005,9 @@ Currently logged-in as ${this._username}.`
|
|
|
7848
8005
|
_BaseUser._initialized = true;
|
|
7849
8006
|
return;
|
|
7850
8007
|
}
|
|
8008
|
+
this.syncStrategy.stopSync?.();
|
|
7851
8009
|
this.setDBandQ();
|
|
8010
|
+
await this.hydrateLocalMirror();
|
|
7852
8011
|
this.syncStrategy.startSync(this.localDB, this.remoteDB);
|
|
7853
8012
|
this.applyDesignDocs().catch((error) => {
|
|
7854
8013
|
log3(`Error in applyDesignDocs background task: ${error}`);
|
|
@@ -7864,6 +8023,85 @@ Currently logged-in as ${this._username}.`
|
|
|
7864
8023
|
});
|
|
7865
8024
|
_BaseUser._initialized = true;
|
|
7866
8025
|
}
|
|
8026
|
+
/**
|
|
8027
|
+
* Pull this account's documents into the local mirror, if that hasn't
|
|
8028
|
+
* happened on this device before.
|
|
8029
|
+
*
|
|
8030
|
+
* Runs between setDBandQ() and startSync() — after the handles exist, before
|
|
8031
|
+
* anything can observe an empty local DB or replicate one upward.
|
|
8032
|
+
*
|
|
8033
|
+
* Never throws: a failure is recorded as `failed` state and reported through
|
|
8034
|
+
* hydrationStatus(), because throwing here would abort init() and leave
|
|
8035
|
+
* BaseUser._initialized false forever (BaseUser.instance() polls it with no
|
|
8036
|
+
* ceiling). Callers decide what a failure means for them.
|
|
8037
|
+
*/
|
|
8038
|
+
async hydrateLocalMirror() {
|
|
8039
|
+
if (this.localDB.name === this.remoteDB.name || !this.syncStrategy.hydrate) {
|
|
8040
|
+
this._hydration = { state: "not-required" };
|
|
8041
|
+
return;
|
|
8042
|
+
}
|
|
8043
|
+
if (await this.hasHydrationMarker()) {
|
|
8044
|
+
this._hydration = { state: "stale" };
|
|
8045
|
+
return;
|
|
8046
|
+
}
|
|
8047
|
+
this._hydration = { state: "hydrating" };
|
|
8048
|
+
const start = Date.now();
|
|
8049
|
+
try {
|
|
8050
|
+
const { docsWritten } = await withTimeout(
|
|
8051
|
+
this.syncStrategy.hydrate(this.localDB, this.remoteDB),
|
|
8052
|
+
HYDRATION_TIMEOUT_MS,
|
|
8053
|
+
`Hydration of local mirror for ${this._username}`
|
|
8054
|
+
);
|
|
8055
|
+
await this.writeHydrationMarker();
|
|
8056
|
+
this._hydration = {
|
|
8057
|
+
state: "hydrated",
|
|
8058
|
+
docsWritten,
|
|
8059
|
+
durationMs: Date.now() - start
|
|
8060
|
+
};
|
|
8061
|
+
log3(
|
|
8062
|
+
`Hydrated local mirror for ${this._username}: ${docsWritten} docs in ${this._hydration.durationMs}ms`
|
|
8063
|
+
);
|
|
8064
|
+
} catch (e) {
|
|
8065
|
+
this.syncStrategy.stopSync?.();
|
|
8066
|
+
this._hydration = {
|
|
8067
|
+
state: "failed",
|
|
8068
|
+
durationMs: Date.now() - start,
|
|
8069
|
+
error: e instanceof Error ? e.message : String(e)
|
|
8070
|
+
};
|
|
8071
|
+
logger.error(`Failed to hydrate local mirror for ${this._username}:`, e);
|
|
8072
|
+
}
|
|
8073
|
+
}
|
|
8074
|
+
/**
|
|
8075
|
+
* Has a full pull completed on this device for the CURRENT account?
|
|
8076
|
+
*
|
|
8077
|
+
* The marker is a `_local/` document, which never replicates — so its
|
|
8078
|
+
* presence describes this device's mirror and cannot arrive from elsewhere.
|
|
8079
|
+
*/
|
|
8080
|
+
async hasHydrationMarker() {
|
|
8081
|
+
try {
|
|
8082
|
+
const marker = await this.localDB.get(HYDRATION_MARKER_ID);
|
|
8083
|
+
return marker.username === this._username;
|
|
8084
|
+
} catch {
|
|
8085
|
+
return false;
|
|
8086
|
+
}
|
|
8087
|
+
}
|
|
8088
|
+
async writeHydrationMarker() {
|
|
8089
|
+
try {
|
|
8090
|
+
let existingRev;
|
|
8091
|
+
try {
|
|
8092
|
+
existingRev = (await this.localDB.get(HYDRATION_MARKER_ID))._rev;
|
|
8093
|
+
} catch {
|
|
8094
|
+
}
|
|
8095
|
+
await this.localDB.put({
|
|
8096
|
+
_id: HYDRATION_MARKER_ID,
|
|
8097
|
+
_rev: existingRev,
|
|
8098
|
+
username: this._username,
|
|
8099
|
+
hydratedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
8100
|
+
});
|
|
8101
|
+
} catch (e) {
|
|
8102
|
+
logger.warn(`Could not write hydration marker for ${this._username}: ${e}`);
|
|
8103
|
+
}
|
|
8104
|
+
}
|
|
7867
8105
|
static designDocs = [
|
|
7868
8106
|
{
|
|
7869
8107
|
_id: "_design/reviewCards",
|
|
@@ -8200,6 +8438,11 @@ Currently logged-in as ${this._username}.`
|
|
|
8200
8438
|
} catch (e) {
|
|
8201
8439
|
const err = e;
|
|
8202
8440
|
if (err.status === 404) {
|
|
8441
|
+
if (!this.canMaterializeDefaults()) {
|
|
8442
|
+
throw new Error(
|
|
8443
|
+
`Cannot read strategy state '${strategyKey}' for ${this._username}: the local mirror is unavailable (hydration state '${this._hydration.state}').`
|
|
8444
|
+
);
|
|
8445
|
+
}
|
|
8203
8446
|
return null;
|
|
8204
8447
|
}
|
|
8205
8448
|
throw e;
|
|
@@ -8723,6 +8966,8 @@ var init_CouchDBSyncStrategy = __esm({
|
|
|
8723
8966
|
CouchDBSyncStrategy = class {
|
|
8724
8967
|
syncHandle;
|
|
8725
8968
|
// Handle to cancel sync if needed
|
|
8969
|
+
/** In-flight one-shot hydration pull, so a timed-out pull can be abandoned. */
|
|
8970
|
+
hydrationHandle;
|
|
8726
8971
|
setupRemoteDB(username) {
|
|
8727
8972
|
if (username === GuestUsername || username.startsWith(GuestUsername)) {
|
|
8728
8973
|
return getLocalUserDB(username);
|
|
@@ -8737,8 +8982,26 @@ var init_CouchDBSyncStrategy = __esm({
|
|
|
8737
8982
|
return this.getUserDB(username);
|
|
8738
8983
|
}
|
|
8739
8984
|
}
|
|
8985
|
+
/**
|
|
8986
|
+
* One-shot remote → local pull. Resolves once the local mirror is caught up.
|
|
8987
|
+
*
|
|
8988
|
+
* Pull only: pushing here would upload whatever the local DB happens to hold
|
|
8989
|
+
* before we know what the remote already has, which is the conflict-leaf
|
|
8990
|
+
* problem hydration exists to avoid. Local changes go up when startSync()
|
|
8991
|
+
* takes over.
|
|
8992
|
+
*/
|
|
8993
|
+
async hydrate(localDB, remoteDB) {
|
|
8994
|
+
const replication = pouchdb_setup_default.replicate(remoteDB, localDB, {});
|
|
8995
|
+
this.hydrationHandle = replication;
|
|
8996
|
+
try {
|
|
8997
|
+
const info = await replication;
|
|
8998
|
+
return { docsWritten: info.docs_written };
|
|
8999
|
+
} finally {
|
|
9000
|
+
this.hydrationHandle = void 0;
|
|
9001
|
+
}
|
|
9002
|
+
}
|
|
8740
9003
|
startSync(localDB, remoteDB) {
|
|
8741
|
-
if (localDB !== remoteDB) {
|
|
9004
|
+
if (localDB.name !== remoteDB.name) {
|
|
8742
9005
|
this.syncHandle = pouchdb_setup_default.sync(localDB, remoteDB, {
|
|
8743
9006
|
live: true,
|
|
8744
9007
|
retry: true
|
|
@@ -8746,8 +9009,20 @@ var init_CouchDBSyncStrategy = __esm({
|
|
|
8746
9009
|
}
|
|
8747
9010
|
}
|
|
8748
9011
|
stopSync() {
|
|
9012
|
+
if (this.hydrationHandle) {
|
|
9013
|
+
try {
|
|
9014
|
+
this.hydrationHandle.cancel();
|
|
9015
|
+
} catch (e) {
|
|
9016
|
+
logger.warn(`Failed to cancel hydration pull (continuing anyway): ${e}`);
|
|
9017
|
+
}
|
|
9018
|
+
this.hydrationHandle = void 0;
|
|
9019
|
+
}
|
|
8749
9020
|
if (this.syncHandle) {
|
|
8750
|
-
|
|
9021
|
+
try {
|
|
9022
|
+
this.syncHandle.cancel();
|
|
9023
|
+
} catch (e) {
|
|
9024
|
+
logger.warn(`Failed to cancel user sync (continuing anyway): ${e}`);
|
|
9025
|
+
}
|
|
8751
9026
|
this.syncHandle = void 0;
|
|
8752
9027
|
}
|
|
8753
9028
|
}
|