Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
62 commits
Select commit Hold shift + click to select a range
ea8d04c
refactor(profile): reuse shared command receipt executor
DavidHLP Sep 22, 2026
b0e0b69
refactor(management): single owner for collection request lifecycle
DavidHLP Sep 22, 2026
a9b3463
refactor(admin): centralize backup object lifecycle
DavidHLP Sep 22, 2026
b1c2f56
refactor(management): move moderation decisions into store seam
DavidHLP Sep 22, 2026
3d50bb5
feat(app): deepen profile mutation module
DavidHLP Sep 22, 2026
bea179c
docs: align architecture docs with deep module decisions
DavidHLP Sep 22, 2026
eb99bfb
fix: close review findings and rollback gaps
DavidHLP Sep 22, 2026
3022d26
test: wire jdbc template in profile integration context
DavidHLP Sep 22, 2026
dd87457
test: restore profile integration auto-configurations
DavidHLP Sep 22, 2026
736dfbd
test(management): drop stale signal assertion in audit workspace
DavidHLP Sep 22, 2026
97758bd
fix(admin): close truncated backup executor configuration test
DavidHLP Sep 22, 2026
e2b69a3
fix(admin): import FileStoragePort in backup lifecycle test
DavidHLP Sep 22, 2026
e26e3f5
fix(app): qualify nested receipt test config in spring boot annotation
DavidHLP Sep 22, 2026
c39cd89
fix(app): extract receipt test config to top-level test class
DavidHLP Sep 22, 2026
271b67f
fix(cd): retarget graceful drain guard to backup object lifecycle
DavidHLP Sep 22, 2026
146886c
fix(admin): restore dubbo auto-configuration exclusion in backup repo…
DavidHLP Sep 22, 2026
5433e77
fix(admin): restore dubbo registry address in shadowing test config
DavidHLP Sep 22, 2026
390dd7b
fix(app): trust function creators for rollback trigger tests
DavidHLP Sep 22, 2026
a05824b
test(admin): prove Spring TaskRejectedException path records FAILED
DavidHLP Sep 22, 2026
9450857
test(admin): always release saturated executor in rejection test
DavidHLP Sep 22, 2026
3592538
fix(management): keep unrelated loading when collection cancel owns n…
DavidHLP Sep 23, 2026
f7ccf98
fix(scripts): probe MySQL readiness over TCP to avoid temp-server race
DavidHLP Sep 23, 2026
2512d52
fix: decouple table loading from mutations and isolate backup object …
DavidHLP Sep 23, 2026
09efe48
refactor(http-client): centralize attempt ownership decisions
DavidHLP Sep 23, 2026
799a515
Merge branch 'arch/t6-attempt-ownership' into arch/impl-arch-review-2…
DavidHLP Sep 23, 2026
3eb420e
refactor(admin): dedupe backup secure-temp helpers and temp-dir default
DavidHLP Sep 23, 2026
348a0fc
refactor(app): centralize profile write policy
DavidHLP Sep 23, 2026
ef8117a
Merge branch 'arch/t4-backup-secure-temp' into arch/impl-arch-review-…
DavidHLP Sep 23, 2026
309e444
refactor(submission): route user reads through assembly
DavidHLP Sep 23, 2026
b74f393
refactor(core): extract owner boot properties
DavidHLP Sep 23, 2026
db69a50
Merge branch 'arch/t3-profile-write-policy' into arch/impl-arch-revie…
DavidHLP Sep 23, 2026
42a3937
Merge branch 'arch/t5-submission-read-seam' into arch/impl-arch-revie…
DavidHLP Sep 23, 2026
5771b6e
refactor: split collection fetch and mutation loading
DavidHLP Sep 23, 2026
b7b53a3
Merge branch 'arch/t9-owner-startup-extraction' into arch/impl-arch-r…
DavidHLP Sep 23, 2026
fa6b0ba
refactor(management): consolidate domain permission maps
DavidHLP Sep 23, 2026
0e0f67b
Merge branch 'arch/t1-collection-loading-ownership' into arch/impl-ar…
DavidHLP Sep 23, 2026
614f231
Merge branch 'arch/t8-permission-consolidation' into arch/impl-arch-r…
DavidHLP Sep 23, 2026
da1def9
refactor(management): extract value request policy
DavidHLP Sep 23, 2026
80b937f
docs(management): clarify moderation stats ownership
DavidHLP Sep 23, 2026
8cd1d85
docs(management): refine moderation request ownership comment
DavidHLP Sep 23, 2026
5ed23e7
Merge branch 'arch/t2-value-request-module' into arch/impl-arch-revie…
DavidHLP Sep 23, 2026
df135ec
refactor(frontend): share request-state policy
DavidHLP Sep 23, 2026
6b98fe2
Merge branch 'arch/t7-shared-request-core' into arch/impl-arch-review…
DavidHLP Sep 23, 2026
1491f5d
refactor(management): delegate collection fetch ownership
DavidHLP Sep 23, 2026
0b669e4
test(management): verify fetch fallback message
DavidHLP Sep 23, 2026
838aed9
Merge branch 'arch/t7b-slice-on-core' into arch/impl-arch-review-2026…
DavidHLP Sep 23, 2026
c1d6ebe
docs: document request-state package boundary
DavidHLP Sep 23, 2026
e9957d7
refactor(app): reuse profile cache eviction constant
DavidHLP Sep 23, 2026
466ef7b
refactor(console): use request-state error refs in stores
DavidHLP Sep 23, 2026
7df6591
style(console): align bookmark request error resets
DavidHLP Sep 23, 2026
e2612f1
Merge branch 'arch/review-fixes' into arch/impl-arch-review-20260923
DavidHLP Sep 23, 2026
34be7a7
fix(console): restore last-writer request errors
DavidHLP Sep 23, 2026
89a2e56
Merge branch 'arch/review-fixes-2' into arch/impl-arch-review-20260923
DavidHLP Sep 23, 2026
92a258f
fix(console): resolve ranking import and problem API test expectation
DavidHLP Sep 23, 2026
5b5d09c
Merge branch 'arch/remote-fixes' into arch/impl-arch-review-20260923
DavidHLP Sep 23, 2026
04e4da9
Merge pull request #218 from DavidHLP/arch/impl-arch-review-20260923
DavidHLP Sep 23, 2026
f183c8c
fix(console): keep past pagination out of page-wide loading
DavidHLP Sep 23, 2026
ba16a60
fix(management): reset sibling error channel on new operations
DavidHLP Sep 23, 2026
df67b35
Merge pull request #219 from DavidHLP/codex/p2-followups
DavidHLP Sep 23, 2026
bba015e
test(management): pin combined error-channel recovery
DavidHLP Sep 23, 2026
f85280c
Merge pull request #220 from DavidHLP/codex/p2-followups
DavidHLP Sep 23, 2026
ac711e5
Merge origin/main into spec line for main delivery
DavidHLP Sep 23, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@ulticode/i18n-completeness": "workspace:*",
"@ulticode/locale-preference": "workspace:*",
"@ulticode/markdown-utils": "workspace:*",
"@ulticode/request-state": "workspace:*",
"@ulticode/sidebar-menu": "workspace:*",
"@ulticode/submission-status": "workspace:*",
"@ulticode/theme": "workspace:*",
Expand Down
4 changes: 3 additions & 1 deletion apps/console/src/api/__tests__/problem.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,8 @@ describe("fetchProblems", () => {
totalPages: 4,
});

const result = await fetchProblems({ search: "two sum" }, 2, 1);
const signal = new AbortController().signal;
const result = await fetchProblems({ search: "two sum" }, 2, 1, signal);

expect(result.items[0]?.title).toBe("Two Sum");
expect(result.total).toBe(4);
Expand All @@ -93,6 +94,7 @@ describe("fetchProblems", () => {
expect(result.totalPages).toBe(4);
expect(apiGet).toHaveBeenCalledWith(
"/problems?page=2&pageSize=1&search=two+sum",
{ signal },
);
});
});
3 changes: 2 additions & 1 deletion apps/console/src/api/problem.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export async function fetchProblems(
filters: ProblemFilters = {},
page: number = 1,
pageSize: number = 50,
signal?: AbortSignal,
): Promise<PageResult<Problem>> {
const params = new URLSearchParams();
params.append("page", String(page));
Expand All @@ -44,7 +45,7 @@ export async function fetchProblems(
if (filters.sortBy) params.append("sortBy", filters.sortBy);
if (filters.sortOrder) params.append("sortOrder", filters.sortOrder);

const response = await apiGet<unknown>(`/problems?${params.toString()}`);
const response = await apiGet<unknown>(`/problems?${params.toString()}`, { signal });
const pageResult = readPage<unknown>(response);
return {
...pageResult,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import type { Component } from "vue";
import type { ProblemExplorerProps } from "../type";
import { CheckCircle2, FileEdit } from "lucide-vue-next";
import { fetchProblems } from "@/api/problem";
import { createValueRequest } from "@ulticode/request-state";
import { toast } from "vue-sonner";
import { useRouter } from "vue-router";
import { PROBLEM_CATEGORIES } from "@/constants/problem-categories";
Expand Down Expand Up @@ -33,11 +34,15 @@ export function useProblemExplorer(props: ProblemExplorerProps) {
const page = ref(1);
const total = ref(0);
const totalPages = ref(1);
const isLoading = ref(false);
const problemRequest = createValueRequest({
errorMessage: "Failed to load problems",
onError: (error) => {
console.error("Failed to load problems", error);
toast.error(t("problem.explorer.failedToLoad"));
},
});
const isLoading = problemRequest.loading;
const searchDebounceTimer = ref<ReturnType<typeof setTimeout> | null>(null);
// Monotonic request token: a fresh search/filter bumps it so an in-flight
// response that was superseded can detect it is stale and discard itself.
const latestRequestToken = ref(0);

const selectedCategory = ref(props.initialCategory || "all");

Expand Down Expand Up @@ -68,46 +73,28 @@ export function useProblemExplorer(props: ProblemExplorerProps) {
};

const loadProblems = async (append = false) => {
// Supplied mode: the caller passed its own problem list, so there is no
// fetch to perform and the watchers/clearFilters/onMounted stay no-op.
if (props.problems !== undefined) return;
// Serialize pagination (scroll-driven load-more can fan); fresh
// searches/filters go through and supersede via latestRequestToken.
if (append && isLoading.value) return;
const myToken = ++latestRequestToken.value;
isLoading.value = true;
try {
const currentPage = append ? page.value : 1;
const result = await fetchProblems(
buildFilters(),
currentPage,
PROBLEMS_PER_PAGE,
);
// A newer search/filter superseded this request — discard the stale
// response so it cannot overwrite newer state.
if (myToken !== latestRequestToken.value) return;
if (append) {
fallbackProblems.value = [...fallbackProblems.value, ...result.items];
} else {
fallbackProblems.value = result.items;
page.value = 1;
}
total.value = result.total;
totalPages.value = result.totalPages;
} catch (error) {
if (myToken !== latestRequestToken.value) return;
console.error("Failed to load problems", error);
toast.error(t("problem.explorer.failedToLoad"));
if (!append) {
const currentPage = append ? page.value : 1;
const result = await problemRequest.run((signal) =>
fetchProblems(buildFilters(), currentPage, PROBLEMS_PER_PAGE, signal),
);
if (!result) {
if (problemRequest.error.value && !append) {
fallbackProblems.value = [];
total.value = 0;
totalPages.value = 1;
}
} finally {
if (myToken === latestRequestToken.value) {
isLoading.value = false;
}
return;
}
if (append) {
fallbackProblems.value = [...fallbackProblems.value, ...result.items];
} else {
fallbackProblems.value = result.items;
page.value = 1;
}
total.value = result.total;
totalPages.value = result.totalPages;
};

onMounted(() => {
Expand Down
57 changes: 10 additions & 47 deletions apps/console/src/composables/useSearch.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { onScopeDispose, ref, type Ref } from "vue";
import { searchApi } from "@/api/search";
import { createValueRequest } from "@ulticode/request-state";
import type { SearchResult } from "@/types/search";

export interface UseSearchOptions {
Expand Down Expand Up @@ -29,32 +30,24 @@ export function useSearch(options: UseSearchOptions = {}): UseSearchReturn {

const query = ref("");
const results = ref<SearchResult[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const request = createValueRequest({ errorMessage: "Search failed" });
const { loading, error } = request;
const total = ref(0);
const selectedIndex = ref(0);
const isOpen = ref(false);

let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let activeController: AbortController | null = null;
let latestRequest = 0;

function cancelPendingSearch(): void {
latestRequest += 1;
request.cancel();

if (debounceTimer) {
clearTimeout(debounceTimer);
debounceTimer = null;
}

activeController?.abort();
activeController = null;
loading.value = false;
}

const search = (q: string): void => {
cancelPendingSearch();
const requestId = latestRequest;

query.value = q;
error.value = null;
Expand All @@ -68,42 +61,12 @@ export function useSearch(options: UseSearchOptions = {}): UseSearchReturn {

debounceTimer = setTimeout(() => {
debounceTimer = null;
if (requestId !== latestRequest) {
return;
}

const controller = new AbortController();
activeController = controller;
loading.value = true;

void searchApi
.search({ query: q, limit }, controller.signal)
.then((response) => {
if (requestId !== latestRequest || controller.signal.aborted) {
return;
}

results.value = response.results;
total.value = response.total;
selectedIndex.value = 0;
})
.catch((err: unknown) => {
if (requestId !== latestRequest || controller.signal.aborted) {
return;
}

error.value = err instanceof Error ? err.message : "Search failed";
results.value = [];
total.value = 0;
})
.finally(() => {
if (requestId === latestRequest) {
loading.value = false;
if (activeController === controller) {
activeController = null;
}
}
});
void request.run((signal) => searchApi.search({ query: q, limit }, signal)).then((response) => {
if (!response) return;
results.value = response.results;
total.value = response.total;
selectedIndex.value = 0;
});
}, debounceMs);
};

Expand Down
21 changes: 17 additions & 4 deletions apps/console/src/stores/__tests__/contestBrowse.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,25 @@ describe("useContestBrowseStore loading state", () => {

expect(store.loadingContests).toBe(false);
});
it("does not write past-load failures into shared browse error", async () => {
vi.mocked(fetchPastContests).mockRejectedValue(new Error("past failed"));
it("keeps past-only pagination out of page-wide loading", async () => {
type ContestPage = PaginatedResult<ContestListItem>;

const past = deferred<ContestPage>();
vi.mocked(fetchPastContests).mockReturnValue(past.promise);

const store = useContestBrowseStore();
store.error = "ongoing failed";
const pastLoad = store.loadPastContests(1, 10);
expect(store.loadingContests).toBe(false);

past.resolve({ items: [], total: 0, page: 1, pageSize: 10, totalPages: 0 });
await pastLoad;
expect(store.loadingContests).toBe(false);
});

it("exposes past-load failures from request-state", async () => {
vi.mocked(fetchPastContests).mockRejectedValue(new Error("past failed"));
const store = useContestBrowseStore();
await expect(store.loadPastContests()).rejects.toThrow("past failed");
expect(store.error).toBe("ongoing failed");
expect(store.error).toBe("past failed");
});
});
33 changes: 20 additions & 13 deletions apps/console/src/stores/achievement.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,15 @@ import { ref, computed } from "vue";
import type { Achievement, AchievementProgress } from "@/types/achievement";
import { achievementApi } from "@/api/achievement";
import { getSocketManager, NotificationEvent } from "@/lib/socket";
import { createValueRequest } from "@ulticode/request-state";

export const useAchievementStore = defineStore("achievement", () => {
const achievements = ref<Achievement[]>([]);
const userAchievements = ref<AchievementProgress[]>([]);
const totalPoints = ref(0);
const loading = ref(false);
const allRequest = createValueRequest({ errorMessage: "Failed to load achievements", rethrow: true });
const userRequest = createValueRequest({ errorMessage: "Failed to load user achievements", rethrow: true });
const loading = computed(() => allRequest.loading.value || userRequest.loading.value);
const initialized = ref(false);
const error = ref<string | null>(null);

Expand Down Expand Up @@ -42,39 +45,39 @@ export const useAchievementStore = defineStore("achievement", () => {

// Actions
async function fetchAll(params?: { category?: string }) {
loading.value = true;
error.value = null;
allRequest.error.value = null;
userRequest.error.value = null;
try {
const result = await achievementApi.getAll(params);
const result = await allRequest.run(() => achievementApi.getAll(params));
if (!result) return null;
achievements.value = result.items;
return result;
} catch (err) {
error.value =
err instanceof Error ? err.message : "Failed to load achievements";
error.value = allRequest.error.value;
throw err;
} finally {
loading.value = false;
}
}

async function fetchUserAchievements() {
loading.value = true;
error.value = null;
allRequest.error.value = null;
userRequest.error.value = null;
try {
const result = await achievementApi.getUserAchievements();
const result = await userRequest.run(() => achievementApi.getUserAchievements());
if (!result) return null;
userAchievements.value = result;
return result;
} catch (err) {
error.value =
err instanceof Error ? err.message : "Failed to load user achievements";
error.value = userRequest.error.value;
throw err;
} finally {
loading.value = false;
}
}

async function fetchUserPoints() {
error.value = null;
allRequest.error.value = null;
userRequest.error.value = null;
try {
const result = await achievementApi.getUserPoints();
totalPoints.value = result.points;
Expand All @@ -90,6 +93,8 @@ export const useAchievementStore = defineStore("achievement", () => {
if (initialized.value) return;

error.value = null;
allRequest.error.value = null;
userRequest.error.value = null;
try {
await Promise.all([fetchUserAchievements(), fetchUserPoints()]);
initialized.value = true;
Expand Down Expand Up @@ -134,6 +139,8 @@ export const useAchievementStore = defineStore("achievement", () => {

function clearError() {
error.value = null;
allRequest.error.value = null;
userRequest.error.value = null;
}

return {
Expand Down
Loading
Loading