Skip to content

Commit 46d331a

Browse files
aster-voidclaude
andcommitted
modules/admin: refactor migration with polling-based progress UI
- Add dedicated /admin/migrate page with real-time log viewer - Create migration-state.server.ts for server-side state management - Add shared types in $lib/shared/types/migration.ts - Implement polling mechanism for progress updates - Remove migration button from QuickActions (use dedicated page) - Use valibot validation instead of type assertions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <[email protected]>
1 parent 7097408 commit 46d331a

File tree

6 files changed

+358
-88
lines changed

6 files changed

+358
-88
lines changed
Lines changed: 1 addition & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,5 @@
11
<script lang="ts">
2-
import { Zap, UserPlus, Pencil, Folder, DatabaseBackup, Loader2 } from "lucide-svelte";
3-
import { runMigration } from "$lib/data/private/migration.remote";
4-
5-
let migrationState = $state<"idle" | "running" | "success" | "error">("idle");
6-
let migrationResult = $state<string>("");
7-
8-
async function handleMigration() {
9-
if (
10-
!confirm("Import data from the old utcode.net repository?\nExisting entries will be skipped.")
11-
) {
12-
return;
13-
}
14-
15-
migrationState = "running";
16-
migrationResult = "";
17-
18-
try {
19-
const result = await runMigration();
20-
migrationState = "success";
21-
migrationResult = [
22-
`Members: ${result.members.created} created, ${result.members.skipped} skipped`,
23-
`Articles: ${result.articles.created} created, ${result.articles.skipped} skipped`,
24-
`Projects: ${result.projects.created} created, ${result.projects.skipped} skipped`,
25-
].join("\n");
26-
27-
const allErrors = [
28-
...result.members.errors,
29-
...result.articles.errors,
30-
...result.projects.errors,
31-
];
32-
if (allErrors.length > 0) {
33-
migrationResult += `\n\nErrors:\n${allErrors.slice(0, 5).join("\n")}`;
34-
if (allErrors.length > 5) {
35-
migrationResult += `\n... and ${allErrors.length - 5} more`;
36-
}
37-
}
38-
} catch (e) {
39-
migrationState = "error";
40-
migrationResult = e instanceof Error ? e.message : String(e);
41-
}
42-
}
2+
import { Zap, UserPlus, Pencil, Folder } from "lucide-svelte";
433
</script>
444

455
<section class="animate-fade-slide-in stagger-5">
@@ -71,28 +31,5 @@
7131
<Folder class="h-4 w-4" />
7232
New Project
7333
</a>
74-
<button
75-
onclick={handleMigration}
76-
disabled={migrationState === "running"}
77-
class="btn gap-2 border-base-300 bg-base-100 font-medium transition-all hover:border-warning/30 hover:bg-warning/5 hover:text-warning disabled:opacity-50"
78-
>
79-
{#if migrationState === "running"}
80-
<Loader2 class="h-4 w-4 animate-spin" />
81-
Migrating...
82-
{:else}
83-
<DatabaseBackup class="h-4 w-4" />
84-
Import Legacy Data
85-
{/if}
86-
</button>
8734
</div>
88-
89-
{#if migrationResult}
90-
<div
91-
class="mt-4 rounded-lg p-4 text-sm whitespace-pre-wrap {migrationState === 'success'
92-
? 'bg-success/10 text-success'
93-
: 'bg-error/10 text-error'}"
94-
>
95-
{migrationResult}
96-
</div>
97-
{/if}
9835
</section>
Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,23 @@
1-
import { command } from "$app/server";
1+
import { command, query } from "$app/server";
22
import { requireUtCodeMember } from "$lib/server/database/auth.server";
3-
import { runDataMigration, type MigrationResult } from "$lib/server/services/migration.server";
3+
import { startDataMigration } from "$lib/server/services/migration.server";
4+
import {
5+
getMigrationState,
6+
resetMigration,
7+
type MigrationState,
8+
} from "$lib/server/services/migration-state.server";
49

5-
export const runMigration = command(async (): Promise<MigrationResult> => {
10+
export const start = command(async (): Promise<{ started: boolean; message: string }> => {
611
await requireUtCodeMember();
7-
return runDataMigration();
12+
return startDataMigration();
13+
});
14+
15+
export const getStatus = query(async (): Promise<MigrationState> => {
16+
await requireUtCodeMember();
17+
return getMigrationState();
18+
});
19+
20+
export const reset = command(async (): Promise<void> => {
21+
await requireUtCodeMember();
22+
resetMigration();
823
});
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Migration state manager - holds stdout/stderr buffer for polling
3+
*/
4+
5+
import type { MigrationState } from "$lib/shared/types/migration";
6+
7+
export type { MigrationState };
8+
9+
const initialState: MigrationState = {
10+
status: "idle",
11+
logs: [],
12+
startedAt: null,
13+
completedAt: null,
14+
result: null,
15+
error: null,
16+
};
17+
18+
// Singleton state
19+
let state: MigrationState = { ...initialState };
20+
21+
export function getMigrationState(): MigrationState {
22+
return { ...state, logs: [...state.logs] };
23+
}
24+
25+
export function isRunning(): boolean {
26+
return state.status === "running";
27+
}
28+
29+
export function startMigration(): void {
30+
state = {
31+
status: "running",
32+
logs: [],
33+
startedAt: new Date(),
34+
completedAt: null,
35+
result: null,
36+
error: null,
37+
};
38+
}
39+
40+
export function log(message: string): void {
41+
state.logs.push(`[${new Date().toISOString().slice(11, 19)}] ${message}`);
42+
}
43+
44+
export function completeMigration(result: MigrationState["result"]): void {
45+
state.status = "completed";
46+
state.completedAt = new Date();
47+
state.result = result;
48+
}
49+
50+
export function failMigration(error: string): void {
51+
state.status = "error";
52+
state.completedAt = new Date();
53+
state.error = error;
54+
}
55+
56+
export function resetMigration(): void {
57+
state = { ...initialState };
58+
}

src/lib/server/services/migration.server.ts

Lines changed: 67 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,16 @@ import {
2020
projectMember,
2121
type ProjectCategory,
2222
} from "$lib/shared/models/schema";
23+
import {
24+
log,
25+
startMigration,
26+
completeMigration,
27+
failMigration,
28+
isRunning,
29+
} from "./migration-state.server";
2330

2431
const REPO_URL = "https://github.com/ut-code/utcode.net.git";
2532

26-
export interface MigrationResult {
27-
members: { created: number; skipped: number; errors: string[] };
28-
articles: { created: number; skipped: number; errors: string[] };
29-
projects: { created: number; skipped: number; errors: string[] };
30-
}
31-
3233
async function runCommand(cmd: string, args: string[], cwd?: string): Promise<void> {
3334
return new Promise((resolve, reject) => {
3435
const child = spawn(cmd, args, { cwd, stdio: "pipe" });
@@ -46,7 +47,9 @@ async function runCommand(cmd: string, args: string[], cwd?: string): Promise<vo
4647

4748
async function cloneRepo(): Promise<string> {
4849
const tempDir = join(tmpdir(), `utcode-migration-${Date.now()}`);
50+
log(`Cloning ${REPO_URL}...`);
4951
await runCommand("git", ["clone", "--depth", "1", REPO_URL, tempDir]);
52+
log("Repository cloned successfully");
5053
return tempDir;
5154
}
5255

@@ -99,12 +102,15 @@ const MemberFrontmatterSchema = v.object({
99102

100103
async function migrateMembers(
101104
repoPath: string,
102-
): Promise<{ created: number; skipped: number; errors: string[] }> {
105+
): Promise<{ created: number; skipped: number; errors: number }> {
106+
log("--- Migrating Members ---");
103107
const membersPath = join(repoPath, "contents/members");
104108
const files = await findMarkdownFiles(membersPath);
109+
log(`Found ${files.length} member files`);
110+
105111
let created = 0;
106112
let skipped = 0;
107-
const errors: string[] = [];
113+
let errorCount = 0;
108114

109115
for (const file of files) {
110116
const relPath = file.replace(membersPath + "/", "");
@@ -124,6 +130,7 @@ async function migrateMembers(
124130
.limit(1);
125131

126132
if (existing.length > 0) {
133+
log(` ⊘ Skipped: ${slug} (already exists)`);
127134
skipped++;
128135
continue;
129136
}
@@ -143,13 +150,17 @@ async function migrateMembers(
143150
pageContent: body || null,
144151
});
145152

153+
log(` ✓ Created: ${slug} (${frontmatter.nameJa})`);
146154
created++;
147155
} catch (e) {
148-
errors.push(`${slug}: ${e instanceof Error ? e.message : String(e)}`);
156+
const msg = e instanceof Error ? e.message : String(e);
157+
log(` ✗ Error: ${slug} - ${msg}`);
158+
errorCount++;
149159
}
150160
}
151161

152-
return { created, skipped, errors };
162+
log(`Members: ${created} created, ${skipped} skipped, ${errorCount} errors`);
163+
return { created, skipped, errors: errorCount };
153164
}
154165

155166
// Article migration
@@ -185,12 +196,15 @@ function generateExcerpt(content: string, maxLength = 200): string {
185196

186197
async function migrateArticles(
187198
repoPath: string,
188-
): Promise<{ created: number; skipped: number; errors: string[] }> {
199+
): Promise<{ created: number; skipped: number; errors: number }> {
200+
log("--- Migrating Articles ---");
189201
const articlesPath = join(repoPath, "contents/articles");
190202
const files = await findMarkdownFiles(articlesPath);
203+
log(`Found ${files.length} article files`);
204+
191205
let created = 0;
192206
let skipped = 0;
193-
const errors: string[] = [];
207+
let errorCount = 0;
194208

195209
for (const file of files) {
196210
const relPath = file.replace(articlesPath + "/", "");
@@ -208,6 +222,7 @@ async function migrateArticles(
208222
.limit(1);
209223

210224
if (existing.length > 0) {
225+
log(` ⊘ Skipped: ${slug} (already exists)`);
211226
skipped++;
212227
continue;
213228
}
@@ -238,13 +253,17 @@ async function migrateArticles(
238253
viewCount: 0,
239254
});
240255

256+
log(` ✓ Created: ${slug}`);
241257
created++;
242258
} catch (e) {
243-
errors.push(`${slug}: ${e instanceof Error ? e.message : String(e)}`);
259+
const msg = e instanceof Error ? e.message : String(e);
260+
log(` ✗ Error: ${slug} - ${msg}`);
261+
errorCount++;
244262
}
245263
}
246264

247-
return { created, skipped, errors };
265+
log(`Articles: ${created} created, ${skipped} skipped, ${errorCount} errors`);
266+
return { created, skipped, errors: errorCount };
248267
}
249268

250269
// Project migration
@@ -279,12 +298,15 @@ function mapCategory(kind: string | undefined): ProjectCategory {
279298

280299
async function migrateProjects(
281300
repoPath: string,
282-
): Promise<{ created: number; skipped: number; errors: string[] }> {
301+
): Promise<{ created: number; skipped: number; errors: number }> {
302+
log("--- Migrating Projects ---");
283303
const projectsPath = join(repoPath, "contents/projects");
284304
const files = await findMarkdownFiles(projectsPath);
305+
log(`Found ${files.length} project files`);
306+
285307
let created = 0;
286308
let skipped = 0;
287-
const errors: string[] = [];
309+
let errorCount = 0;
288310

289311
for (const file of files) {
290312
const dirPath = dirname(file);
@@ -303,6 +325,7 @@ async function migrateProjects(
303325
.limit(1);
304326

305327
if (existing.length > 0) {
328+
log(` ⊘ Skipped: ${slug} (already exists)`);
306329
skipped++;
307330
continue;
308331
}
@@ -344,16 +367,34 @@ async function migrateProjects(
344367
}
345368
}
346369

370+
log(` ✓ Created: ${slug} (${name})`);
347371
created++;
348372
} catch (e) {
349-
errors.push(`${slug}: ${e instanceof Error ? e.message : String(e)}`);
373+
const msg = e instanceof Error ? e.message : String(e);
374+
log(` ✗ Error: ${slug} - ${msg}`);
375+
errorCount++;
350376
}
351377
}
352378

353-
return { created, skipped, errors };
379+
log(`Projects: ${created} created, ${skipped} skipped, ${errorCount} errors`);
380+
return { created, skipped, errors: errorCount };
381+
}
382+
383+
export function startDataMigration(): { started: boolean; message: string } {
384+
if (isRunning()) {
385+
return { started: false, message: "Migration already in progress" };
386+
}
387+
388+
startMigration();
389+
log("=== Data Migration Started ===");
390+
391+
// Run migration in background (fire and forget with proper error handling)
392+
runMigrationAsync().catch(console.error);
393+
394+
return { started: true, message: "Migration started" };
354395
}
355396

356-
export async function runDataMigration(): Promise<MigrationResult> {
397+
async function runMigrationAsync(): Promise<void> {
357398
let repoPath: string | null = null;
358399

359400
try {
@@ -365,10 +406,16 @@ export async function runDataMigration(): Promise<MigrationResult> {
365406
const articles = await migrateArticles(repoPath);
366407
const projects = await migrateProjects(repoPath);
367408

368-
return { members, articles, projects };
409+
log("=== Migration Complete ===");
410+
completeMigration({ members, articles, projects });
411+
} catch (e) {
412+
const msg = e instanceof Error ? e.message : String(e);
413+
log(`=== Migration Failed: ${msg} ===`);
414+
failMigration(msg);
369415
} finally {
370416
// Cleanup
371417
if (repoPath) {
418+
log("Cleaning up temporary files...");
372419
await rm(repoPath, { recursive: true, force: true }).catch(() => {});
373420
}
374421
}

src/lib/shared/types/migration.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
export type MigrationStatus = "idle" | "running" | "completed" | "error";
2+
3+
export interface MigrationState {
4+
status: MigrationStatus;
5+
logs: string[];
6+
startedAt: Date | null;
7+
completedAt: Date | null;
8+
result: {
9+
members: { created: number; skipped: number; errors: number };
10+
articles: { created: number; skipped: number; errors: number };
11+
projects: { created: number; skipped: number; errors: number };
12+
} | null;
13+
error: string | null;
14+
}

0 commit comments

Comments
 (0)