Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .agents/skills/life-ustc-implement/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description: "Add or change Life@USTC server behavior end-to-end: contracts, src
# Implement a Life@USTC server change

How to land a behavior change in this repo. Layout and local checks: root
`AGENTS.md`. Git / PR / CI / merge: global skills, not this file.
`AGENTS.md`.

## When to use

Expand Down
13 changes: 13 additions & 0 deletions .github/dependabot.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
version: 2
updates:
- package-ecosystem: npm
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 10

- package-ecosystem: github-actions
directory: /
schedule:
interval: weekly
open-pull-requests-limit: 10
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ jobs:
with:
database-name: life_ustc_ci
job-phase: ci:verify
upload-artifact-name: vitest-coverage
upload-artifact-path: coverage
upload-artifact-no-files-found: warn

test-integration:
name: MCP Integration Test
Expand Down
19 changes: 2 additions & 17 deletions .github/workflows/db-backed-bun-job.yml
Original file line number Diff line number Diff line change
Expand Up @@ -156,7 +156,7 @@ jobs:
bunx tsc --noEmit -p tsconfig.typecheck.json
bunx tsc --noEmit -p tsconfig.typecheck.tests.json
bunx tsc --noEmit -p tsconfig.typecheck.operational.json
bunx vitest run
bunx vitest run --coverage
;;
ci:integration)
# Match wrangler.e2e.jsonc so JWKS rows seeded here decrypt when
Expand Down Expand Up @@ -231,22 +231,7 @@ jobs:
tests/integration/function-owner-role-contract.test.ts
bunx vitest run \
--config vitest.integration.config.ts \
tests/integration/todo-rls.test.ts
bunx vitest run \
--config vitest.integration.config.ts \
tests/integration/personal-preferences-rls.test.ts
bunx vitest run \
--config vitest.integration.config.ts \
tests/integration/comment-reaction-rls.test.ts
bunx vitest run \
--config vitest.integration.config.ts \
tests/integration/comment-rls.test.ts
bunx vitest run \
--config vitest.integration.config.ts \
tests/integration/upload-rls.test.ts
bunx vitest run \
--config vitest.integration.config.ts \
tests/integration/homework-completion-rls.test.ts
tests/integration/*-rls.test.ts
;;
ci:e2e:build-artifacts)
bun run app:prepare
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Start here instead of grepping the whole tree. Nested `AGENTS.md` files go
deeper on one area (closest file wins). Shared names live in
`docs/interface-hierarchy.md` and `docs/contracts/`. To add or change behavior,
use `$life-ustc-implement`. Git / PR / CI / merge: global skills, not this repo.
use `$life-ustc-implement`.

## How the system fits together

Expand Down
23 changes: 22 additions & 1 deletion bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions docs/contracts/ical.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"copyable-links": "iCal links should be presented as copyable links rather than requiring users to manually construct them.",
"no-second-model": "iCal is an export / subscription capability and should not carry a second business model.",
"rfc5545-dtstamp-utc": "Every VEVENT in section, multi-section, and personal feeds serializes DTSTAMP as a UTC DATE-TIME with a Z suffix while retaining Asia/Shanghai TZID values for event start and end times.",
"personal-feed-cache": "Personal feed responses may be served from a private Workers KV-backed server-side cache with a five-minute fresh window and a bounded stale fallback during refresh failures, include ETag, and return 304 Not Modified for matching If-None-Match. Feed-token authorization is checked against the current user record before any cached export is returned. Cache telemetry must never include user IDs or feed tokens.",
"personal-feed-cache": "Personal feeds may use a private Workers KV cache (30m fresh / 24h stale). Fresh hits serve immediately; stale hits serve cached ICS and enqueue CALENDAR_EXPORT_REBUILD. True misses rebuild on the request path (KV put may defer). Write-time invalidation enqueues rebuilds instead of waitUntil deletes. Include ETag/304. Check the current feed token before any cache read; revoked tokens for an existing user return 410 with short Cache-Control. Never put user IDs or feed tokens in cache telemetry.",
"static-json-resilient": "Calendar location and building image enhancements only read static JSON published at https://static.life-ustc.tiankaima.dev; when loading fails or data is corrupted, diagnostic information should be logged and a valid iCal should still be returned."
},
"capabilities": {
Expand Down Expand Up @@ -61,7 +61,8 @@
"notes": [
"The opaque credential authorizes feed retrieval.",
"The credential is carried only in the canonical path segment.",
"An existing user with a valid feed token receives a valid empty VCALENDAR when no items are currently available, so calendar clients can keep the subscription active."
"An existing user with a valid feed token receives a valid empty VCALENDAR when no items are currently available, so calendar clients can keep the subscription active.",
"When the user exists but the feed token is wrong or revoked, the route returns 410 Gone with a short Cache-Control so calendar clients stop aggressive re-polling; unknown users return 404."
]
},
{
Expand Down
1 change: 0 additions & 1 deletion docs/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@

Start with root [`AGENTS.md`](../AGENTS.md). For an end-to-end change, use
[`$life-ustc-implement`](../.agents/skills/life-ustc-implement/SKILL.md).
Git / PR / CI / merge: global agent skills.

## Start here

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.0",
"@types/node": "^25.9.1",
"@vitest/coverage-v8": "4.1.8",
"ajv": "^8.20.0",
"bits-ui": "^2.18.1",
"mode-watcher": "^1.1.0",
Expand Down
10 changes: 10 additions & 0 deletions public/openapi.generated.json
Original file line number Diff line number Diff line change
Expand Up @@ -1268,6 +1268,16 @@
}
}
}
},
"410": {
"description": "Error response",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/openApiErrorSchema"
}
}
}
}
}
}
Expand Down
94 changes: 38 additions & 56 deletions src/features/calendar/server/calendar-export-cache.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
import {
getCloudflareCalendarExportsNamespace,
getCloudflareRuntimeTaskScheduler,
} from "@/lib/adapters/cloudflare-runtime";
import { enqueueUserCalendarExportRebuild } from "@/features/calendar/server/calendar-export-queue";
import { getCloudflareCalendarExportsNamespace } from "@/lib/adapters/cloudflare-runtime";
import { sha256Base64Url } from "@/lib/crypto/web-crypto";
import { writeCalendarFeedCacheAnalytics } from "@/lib/metrics/analytics-engine";

Expand Down Expand Up @@ -150,6 +148,32 @@ async function persistStoredCalendar(
}
}

/**
* Persist a built ICS export to isolate memory + KV (queue rebuild / sync miss).
*/
export async function storeBuiltUserCalendarExport(
userId: string,
calendar: UserCalendarExport,
options: UserCalendarExportCacheOptions = {},
) {
const stored: StoredUserCalendarExport = {
...calendar,
etag: await createCalendarEtag(calendar.text),
generatedAtMs: Date.now(),
version: USER_CALENDAR_EXPORT_CACHE_VERSION,
};
userCalendarExportCache.set(userId, stored);
pruneOldestEntries();
const persistence = persistStoredCalendar(userId, stored);
if (options.defer) {
options.defer(persistence);
} else {
await persistence;
}
recordCalendarFeedCacheStatus("refresh_success");
return stored;
}

function refreshUserCalendarExport(
userId: string,
buildExport: () => Promise<UserCalendarExport | null>,
Expand All @@ -161,23 +185,7 @@ function refreshUserCalendarExport(
const refresh = (async () => {
const calendar = await buildExport();
if (!calendar) return null;

const stored: StoredUserCalendarExport = {
...calendar,
etag: await createCalendarEtag(calendar.text),
generatedAtMs: Date.now(),
version: USER_CALENDAR_EXPORT_CACHE_VERSION,
};
userCalendarExportCache.set(userId, stored);
pruneOldestEntries();
const persistence = persistStoredCalendar(userId, stored);
if (defer) {
defer(persistence);
} else {
await persistence;
}
recordCalendarFeedCacheStatus("refresh_success");
return stored;
return storeBuiltUserCalendarExport(userId, calendar, { defer });
})();

userCalendarExportRefreshes.set(userId, refresh);
Expand All @@ -188,6 +196,12 @@ function refreshUserCalendarExport(
return refresh;
}

function scheduleStaleCalendarExportRebuild(userId: string) {
void enqueueUserCalendarExportRebuild(userId).catch(() => {
// Stale-serve path must never fail because enqueue failed.
});
}

export async function getCachedUserCalendarExport(
userId: string,
buildExport: () => Promise<UserCalendarExport | null>,
Expand All @@ -208,31 +222,9 @@ export async function getCachedUserCalendarExport(
}

if (ageMs <= USER_CALENDAR_EXPORT_STALE_TTL_MS) {
// Do not rebuild ICS inside waitUntil/defer. Production showed Worker wall
// ~12s / CPU p95 ~3s on this path (cpu_ms limit 1000) while the response
// itself returned in ~100ms — calendar clients re-poll and the deferred
// rebuilds cancel or hit the CPU limit. Freshness comes from write-time
// invalidation plus a sync rebuild once the entry is past STALE_TTL.
if (options.defer) {
recordCalendarFeedCacheStatus("stale");
return {
calendar: cached,
status: "stale" satisfies UserCalendarExportCacheStatus,
};
}

try {
const refreshed = await refreshUserCalendarExport(userId, buildExport);
if (refreshed) {
return {
calendar: refreshed,
status: "miss" satisfies UserCalendarExportCacheStatus,
};
}
} catch {
recordCalendarFeedCacheStatus("refresh_error");
}

// Serve stale immediately and enqueue a Queue rebuild. Do not rebuild ICS
// on the request path (or inside waitUntil) — that path hit cpu_ms / cancel.
scheduleStaleCalendarExportRebuild(userId);
recordCalendarFeedCacheStatus("stale");
return {
calendar: cached,
Expand Down Expand Up @@ -273,13 +265,3 @@ export async function invalidateUserCalendarExportCache(userId: string) {
recordCalendarFeedCacheStatus("store_error");
}
}

export function scheduleInvalidateUserCalendarExportCache(userId: string) {
const scheduleTask = getCloudflareRuntimeTaskScheduler();
const work = invalidateUserCalendarExportCache(userId);
if (scheduleTask) {
scheduleTask(work);
return;
}
void work;
}
Loading