Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
ba340b8
feat(db): add section lifecycle storage
tiankaima Jul 17, 2026
f44a044
Merge remote-tracking branch 'origin/main' into agent/stale-section-l…
tiankaima Jul 17, 2026
e0bcbc5
feat(static-loader): add reversible section lifecycle
tiankaima Jul 17, 2026
40584ec
Merge remote-tracking branch 'origin/main' into agent/stale-section-l…
tiankaima Jul 17, 2026
bdf4ad0
fix(static-loader): reconcile empty section teacher sets
tiankaima Jul 17, 2026
d998ce6
fix(static-loader): persist global snapshot watermark
tiankaima Jul 17, 2026
3729278
fix(subscriptions): preserve requested retired sections
tiankaima Jul 17, 2026
d44e1bd
feat(static-loader): add reversible section lifecycle
tiankaima Jul 17, 2026
de52b99
fix(static-loader): preserve schema enum order
tiankaima Jul 17, 2026
6f4d6b7
fix(sections): close lifecycle safety gaps
tiankaima Jul 17, 2026
0064ca2
Merge remote-tracking branch 'origin/main' into agent/stale-section-l…
tiankaima Jul 17, 2026
eec775c
Merge clean lifecycle candidate after safety fixes
tiankaima Jul 17, 2026
5c7f1d4
Merge remote-tracking branch 'origin/agent/stale-section-lifecycle' i…
tiankaima Jul 17, 2026
9fc9005
fix(dashboard): exclude retired sections from public count
tiankaima Jul 17, 2026
2345517
Merge active dashboard count fix
tiankaima Jul 17, 2026
ec6a5e3
Merge parallel lifecycle safety fixes
tiankaima Jul 17, 2026
a8a4760
fix(subscriptions): serialize section retirement races
tiankaima Jul 17, 2026
58ee701
fix(static-loader): shorten lifecycle lock window
tiankaima Jul 17, 2026
af33a8e
fix(static-loader): package lifecycle lock helper
tiankaima Jul 17, 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
11 changes: 11 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -40,3 +40,14 @@ AUTH_OIDC_CLIENT_SECRET=""
# When E2E_DEBUG_AUTH=1 (e.g. Playwright), set both — no defaults in non-dev NODE_ENV:
# DEV_DEBUG_PASSWORD="..."
# DEV_ADMIN_PASSWORD="..."

# Static loader only. Missing Sections are never retired unless this is set to
# true after the production backup/restore prerequisite has been verified.
# The first committed import after adding the global watermark must be a manual
# bootstrap with this flag plus the exact SHA-256 from a preceding dry run.
# Destructive runs also require the exact SHA-256 and candidate count observed
# in a preceding dry run.
# STATIC_LOADER_BOOTSTRAP_IMPORT_STATE="false"
# STATIC_LOADER_RETIRE_MISSING_SECTIONS="false"
# STATIC_LOADER_EXPECTED_SNAPSHOT_SHA256=""
# STATIC_LOADER_EXPECTED_SECTION_RETIREMENT_CANDIDATES=""
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ jobs:
-v "${report_dir}:/tmp/static-loader-report" \
-e DATABASE_URL="postgresql://postgres:postgres@${postgres}:5432/life_ustc_static_loader" \
-e STATIC_LOADER_DRY_RUN=true \
-e STATIC_LOADER_BOOTSTRAP_IMPORT_STATE=true \
-e STATIC_LOADER_MIN_SEMESTER=401 \
-e STATIC_LOADER_STATS_FILE=/tmp/static-loader-report/report.json \
life-ustc-static-loader:check
Expand All @@ -119,6 +120,11 @@ jobs:
report.mode !== "dry-run" ||
report.outcome !== "rolled-back" ||
report.databaseRecordCounts !== null ||
report.reconciliation?.sectionLifecycle?.enabled !== false ||
!Number.isInteger(
report.reconciliation?.sectionLifecycle
?.retirementCandidateCount,
) ||
!/^[a-f0-9]{64}$/.test(report.snapshot?.sha256 ?? "")
) {
throw new Error("Static loader report has invalid dry-run semantics");
Expand Down
22 changes: 22 additions & 0 deletions .github/workflows/static-sync.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,24 @@ on:
required: false
default: false
type: boolean
bootstrap_import_state:
description: "Initialize the global import watermark (manual only; committed runs require the exact snapshot SHA below)"
required: false
default: false
type: boolean
retire_missing_sections:
description: "Retire missing Sections (manual runs only; requires both approval fields below)"
required: false
default: false
type: boolean
expected_snapshot_sha256:
description: "Exact snapshot SHA-256 approved from a preceding bootstrap/retirement dry run"
required: false
type: string
expected_section_retirement_candidates:
description: "Exact missing-Section candidate count approved from a preceding dry run"
required: false
type: string

concurrency:
group: production-database-writes
Expand Down Expand Up @@ -55,6 +73,10 @@ jobs:
STATIC_SNAPSHOT_URL: ${{ vars.STATIC_SNAPSHOT_URL || 'https://static.life-ustc.tiankaima.dev/life-ustc-static.sqlite' }}
STATIC_LOADER_MIN_SEMESTER: ${{ vars.STATIC_LOADER_MIN_SEMESTER || '401' }}
STATIC_LOADER_DRY_RUN: ${{ inputs.dry-run }}
STATIC_LOADER_BOOTSTRAP_IMPORT_STATE: ${{ github.event_name == 'workflow_dispatch' && inputs.bootstrap_import_state || false }}
STATIC_LOADER_RETIRE_MISSING_SECTIONS: ${{ github.event_name == 'workflow_dispatch' && inputs.retire_missing_sections || false }}
STATIC_LOADER_EXPECTED_SNAPSHOT_SHA256: ${{ github.event_name == 'workflow_dispatch' && inputs.expected_snapshot_sha256 || '' }}
STATIC_LOADER_EXPECTED_SECTION_RETIREMENT_CANDIDATES: ${{ github.event_name == 'workflow_dispatch' && inputs.expected_section_retirement_candidates || '' }}
STATIC_LOADER_STATS_FILE: /tmp/static-load-stats.json
run: scripts/load-static-sqlite.sh

Expand Down
1 change: 1 addition & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ COPY package.json prisma.config.ts ./
COPY prisma ./prisma
RUN DATABASE_URL="postgresql://localhost:5432/life_ustc" bun run db:generate
COPY src/static-loader ./src/static-loader
COPY src/lib/db/section-lifecycle-lock.ts ./src/lib/db/section-lifecycle-lock.ts
COPY scripts/load-static-sqlite.sh ./scripts/load-static-sqlite.sh
COPY docker-entrypoint.load.sh /usr/local/bin/docker-entrypoint.load.sh

Expand Down
4 changes: 4 additions & 0 deletions docker-compose.load.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ services:
STATIC_SNAPSHOT_PATH: ${STATIC_SNAPSHOT_PATH:-}
STATIC_LOADER_MIN_SEMESTER: ${STATIC_LOADER_MIN_SEMESTER:-401}
STATIC_LOADER_DRY_RUN: ${STATIC_LOADER_DRY_RUN:-false}
STATIC_LOADER_BOOTSTRAP_IMPORT_STATE: ${STATIC_LOADER_BOOTSTRAP_IMPORT_STATE:-false}
STATIC_LOADER_RETIRE_MISSING_SECTIONS: ${STATIC_LOADER_RETIRE_MISSING_SECTIONS:-false}
STATIC_LOADER_EXPECTED_SNAPSHOT_SHA256: ${STATIC_LOADER_EXPECTED_SNAPSHOT_SHA256:-}
STATIC_LOADER_EXPECTED_SECTION_RETIREMENT_CANDIDATES: ${STATIC_LOADER_EXPECTED_SECTION_RETIREMENT_CANDIDATES:-}
# To use a local snapshot file instead of downloading, set STATIC_SNAPSHOT_PATH
# and mount the host file to the same path inside the container, for example:
# volumes:
Expand Down
10 changes: 9 additions & 1 deletion docs/contracts/_audit.json
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,14 @@
"trigger": "DELETE /api/uploads/[id] or MCP delete_my_upload",
"description": "User deletes an upload they own. MCP-originated writes include metadata.source=mcp. After storage deletion succeeds, metadata deletion and the audit row are committed together."
},
"section_retire": {
"trigger": "Reserved",
"description": "The importer marks one missing in-scope Section retired without deleting the row or children. The same transaction records the Section target, source snapshot SHA-256, observation time, jwId, and previous sourceLastSeenAt."
},
"section_reactivate": {
"trigger": "Reserved",
"description": "The importer clears retiredAt and refreshes sourceLastSeenAt. The same transaction records the Section target, source snapshot SHA-256, observation time, jwId, and previous retiredAt."
},
"admin_user_suspend": {
"trigger": "POST /api/admin/suspensions",
"description": "Admin suspends a user account. The suspension change and audit row are committed together."
Expand All @@ -41,5 +49,5 @@
"description": "Admin updates description content from the moderation workflow. The description row, edit history, and audit row are committed together."
}
},
"writer": "writeAuditLog records audit rows and Cloudflare Analytics Engine datapoints. Required domain/security trails await writeAuditLog and use the same Prisma transaction as DB-only mutations where practical. fireAuditLog is reserved for non-critical telemetry and logs failures without blocking the request."
"writer": "writeAuditLog records app audit rows and Cloudflare Analytics Engine datapoints. The Node-only static loader writes Section lifecycle rows directly with its import transaction. Required trails are awaited in the same transaction where practical; fireAuditLog remains non-critical telemetry."
}
1 change: 1 addition & 0 deletions docs/contracts/calendar.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"rules": {
"views": "Provides selectable semester, month, and week views.",
"retired-sections": "Current/public collection calendars, personal calendar views, feeds, and calendar event tools exclude retired Sections. A direct single-Section detail/calendar URL remains available as a historical reference, and retirement does not delete underlying schedules, exams, homeworks, or subscription relationships.",
"mobile-agenda-first": "At mobile widths, the personal calendar presents the selected week as a vertically readable agenda with time, title, type, and available location context. Previous week, today, next week, and iCal actions remain directly accessible without page-level horizontal scrolling; desktop retains the selectable grid views.",
"week-starts-sunday": "The week view starts on Sunday.",
"event-card-types": "Calendar event cards uniformly carry course, exam, homework, and todo events."
Expand Down
1 change: 1 addition & 0 deletions docs/contracts/course.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"static-section-course-link": "Each imported Section resolves the exact Course semantic identity attached to its lesson parent. Static sync assigns that link when inserting a Section but never silently rebinds courseId on an existing Section; missing identity mappings abort the transaction.",
"static-legacy-course-merge": "After resolving current snapshot identities, static sync merges only stale Course rows absent from the snapshot that have exactly one compatible canonical match with the same code, Chinese name, and complete classification metadata. The merge preserves Sections, Comments, compatible Description history and latest edit metadata, and the stale jwId as a CourseAlias; ambiguous matches, conflicting descriptions, and alias collisions abort the transaction.",
"course-jwid-alias": "Legacy course jwIds remain valid for REST, MCP, comment and description targets. Course and CourseAlias jwId namespaces are globally disjoint, and collisions abort instead of resolving ambiguously. Web detail routes permanently redirect aliases to the canonical jwId, while serialized course data always reports the canonical jwId.",
"retired-section-history": "Direct Course detail retains retired Section offerings as historical references; public Section discovery excludes them.",
"jwid-url-only": "jwId is used only for URLs, APIs, MCP, and backend logic; it should not be directly displayed in ordinary course UI.",
"education-level-display-casing": "English education-level filter labels uppercase only their initial character for consistent display while preserving all remaining source casing; non-English labels remain unchanged.",
"public-list-cache": "Anonymous course list REST responses and server-side list data may use short public runtime/CDN caching because course fact data is read-only; localized HTML pages must not use shared edge caching unless the cache key varies by locale.",
Expand Down
19 changes: 15 additions & 4 deletions docs/contracts/section.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
},
"rules": {
"semester-offering": "A section is a specific teaching class for a course in a given semester.",
"source-lifecycle": "Before any model write, static sync rejects generated_at values more than 15 minutes in the future and compares each import with one transactionally persisted global generated_at/SHA-256 watermark. Older snapshots and same-time/different-SHA snapshots fail. It records sourceLastSeenAt from the validated time. A missing in-scope Section may set retiredAt only after completeness and mapped-jwId validation; reappearing rows clear retiredAt only when seen after retirement.",
"import-state-bootstrap": "When no global import watermark exists, scheduled and ordinary manual writes fail closed. A manual bootstrap must keep Section retirement disabled; a committed bootstrap also requires the exact snapshot SHA-256 observed in a preceding dry run. The watermark update commits in the same advisory-locked transaction as all imported models.",
"retirement-approval": "Scheduled production sync can never enable retirement. A manual run must explicitly set STATIC_LOADER_RETIRE_MISSING_SECTIONS=true and provide the exact snapshot SHA-256 plus exact candidate count observed in a preceding dry run; any mismatch aborts before lifecycle writes. Semesters with zero source lessons are excluded from destructive scope.",
"retirement-preserves-data": "Retirement never deletes the Section or its comments, description, homeworks, subscription relationships, schedules, exams, or audit history. Each retirement/reactivation writes a Section-targeted audit row and the import report includes candidate, before/after, retired, and reactivated counts.",
"retired-read-semantics": "Public Section/search/match and public/current calendar collection queries exclude retired rows. Direct Section detail and single-Section calendar access remain available for historical references; the detail page labels the record historical and does not offer a new subscription. Course/Teacher detail histories and the owner's subscribed Sections list also retain retired rows. New subscription discovery/add operations reject retired rows, while an existing owner can still remove one.",
"subscription-not-enrollment": "Pages involving subscription actions must clearly state this is not official academic course enrollment.",
"jwid-url-only": "jwId is used only for routing and API endpoints; it should not be directly displayed in ordinary section UI.",
"public-list-cache": "Anonymous section list REST responses and server-side list data may use short public runtime/CDN caching because section fact data is read-only; localized HTML pages must not use shared edge caching unless the cache key varies by locale.",
Expand All @@ -30,7 +35,8 @@
"path": "/api/sections",
"returns": "PaginatedResponse<SectionSummary>",
"notes": [
"REST pageSize accepts 1-100; the deprecated limit alias remains accepted and pageSize wins when both are supplied. Out-of-range values are rejected instead of clamped."
"REST pageSize accepts 1-100; the deprecated limit alias remains accepted and pageSize wins when both are supplied. Out-of-range values are rejected instead of clamped.",
"Retired Sections are excluded."
]
}
]
Expand All @@ -43,7 +49,8 @@
"rest_equivalent": "GET /api/sections",
"mcp_equivalent": "search_sections",
"notes": [
"PageInput defaults to page=1 and pageSize=20; pageSize must remain within 1-100."
"PageInput defaults to page=1 and pageSize=20; pageSize must remain within 1-100.",
"Retired Sections are excluded."
]
}
]
Expand All @@ -55,7 +62,8 @@
"returns": "PaginatedResponse<SectionSummary>",
"rest_equivalent": "GET /api/sections",
"notes": [
"MCP limit accepts 1-100 and rejects out-of-range values instead of clamping."
"MCP limit accepts 1-100 and rejects out-of-range values instead of clamping.",
"Retired Sections are excluded."
]
}
]
Expand Down Expand Up @@ -91,7 +99,10 @@
"routes": [
{
"path": "/api/sections/[jwId]",
"returns": "SectionDetail (schedules without room/teacher expansion)"
"returns": "SectionDetail (schedules without room/teacher expansion)",
"notes": [
"Direct historical lookup remains available for retired Sections; retiredAt identifies retirement and sourceLastSeenAt records the last successful source observation."
]
}
]
},
Expand Down
1 change: 1 addition & 0 deletions docs/contracts/subscribed-sections.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
},
"rules": {
"grouped-by-semester": "The list is grouped by semester rather than presented as a single flat list.",
"retired-history": "Existing subscription relationships to retired Sections remain visible in the owner's subscribed Sections list and can be removed. Retired Sections cannot be newly discovered or added and are omitted from current personal calendar events/feeds.",
"section-codes-promoted": "In the 'subscribed context', users manage specific section relationships, so section codes are intentionally promoted.",
"legacy-sections-route": "/dashboard/subscriptions/sections is a legacy redirect-only route to /dashboard/subscriptions and must not expose page actions."
},
Expand Down
7 changes: 4 additions & 3 deletions docs/contracts/subscription.json
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,8 @@
"notes": [
"Preferred mutation endpoint for batch subscribe, unsubscribe, and replace/update flows.",
"Accepts action plus sectionIds and/or mixed section/course codes.",
"action=set requires semesterId and replaces only the requested semester, preserving subscriptions from every other semester.",
"A semester-scoped action=set may omit sectionIds/codes to clear only that semester."
"action=set requires semesterId, replaces only that semester (an empty target clears it), and preserves every other semester.",
"action=set preserves a retired Section only when it is already subscribed and its ID is explicitly included; omitted or newly requested retired Sections remain unsubscribed."
]
},
{
Expand All @@ -114,7 +114,8 @@
"returns": "{ subscription: CalendarSubscription }",
"notes": [
"Compatibility replace endpoint for ID-only callers.",
"subscription.sections[] is the user's new set of subscribed sections."
"subscription.sections[] is the user's new set of subscribed sections.",
"A retired Section is preserved only when it is already subscribed and its ID is explicitly included; retired Sections cannot be newly subscribed."
]
},
{
Expand Down
2 changes: 2 additions & 0 deletions messages/en-us.json
Original file line number Diff line number Diff line change
Expand Up @@ -1052,6 +1052,8 @@
"courseCode": "Course Code",
"sectionCode": "Section Code",
"teachingSection": "Teaching section",
"historicalSectionLabel": "Historical section",
"historicalSectionDescription": "This section is absent from the latest academic snapshot. Its history remains available, but it cannot be newly followed.",
"semester": "Semester",
"campus": "Campus",
"credits": "Credits",
Expand Down
2 changes: 2 additions & 0 deletions messages/zh-cn.json
Original file line number Diff line number Diff line change
Expand Up @@ -1018,6 +1018,8 @@
"courseCode": "课程代码",
"sectionCode": "班级代码",
"teachingSection": "授课班级",
"historicalSectionLabel": "历史班级",
"historicalSectionDescription": "该班级已不在最新教务快照中,保留此页面用于查看历史信息;不能新增关注。",
"semester": "学期",
"campus": "校区",
"credits": "学分",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
CREATE TABLE "StaticImportState" (
"id" TEXT NOT NULL,
"snapshotGeneratedAt" TIMESTAMP(3) NOT NULL,
"snapshotSha256" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,

CONSTRAINT "StaticImportState_pkey" PRIMARY KEY ("id"),
CONSTRAINT "StaticImportState_snapshotSha256_check"
CHECK ("snapshotSha256" ~ '^[0-9a-f]{64}$')
);
10 changes: 10 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,16 @@ model Section {
@@index([teachLanguageId])
}

model StaticImportState {
id String @id

snapshotGeneratedAt DateTime
snapshotSha256 String

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}

model Exam {
id Int @id @default(autoincrement())
jwId Int @unique
Expand Down
Loading