Skip to content

Fix/code review 16 - #1

Merged
cvega merged 11 commits into
mainfrom
fix/code-review-16
Feb 15, 2026
Merged

cvega merged 11 commits into
mainfrom
fix/code-review-16

Conversation

@cvega

@cvega cvega commented Feb 15, 2026 •

Copy link
Copy Markdown
Owner

cvega added 11 commits February 14, 2026 22:53
- #1: Add safeJsonParse<T>() for rowToMigration and getEvents JSON columns
- #4: Add idx_migrations_state and idx_migrations_batch_id indexes
- #10: Replace atob with Buffer.from for UTF-8 correctness in basic auth
- #12: Extract formatElapsed to $lib/format.ts, remove 4 duplicates
- #14: Replace magic string context key with typed Symbol in $lib/context-keys.ts
- #15: Cap failedAttempts Map at 10K entries with LRU eviction
- Also: Add comment clarifying MAX_CONCURRENT is a GitHub limit
- #5: Replace N+1 listBatchesPaginated with single SQL aggregate query
- #16: Refactor getBatchSummary to reuse getBatchListItem for counts
- Remove dead listBatches/listBatchIds functions
Extract createEmitHandler(id) factory, handlePipelineResult(), and
handlePipelineError() to eliminate ~100 lines of duplicated emit
callback logic between start() and recoverOrphans().

Fix #2: DRY up the emit callback duplication.
- Replace `as unknown as T` double-casts with narrowBody<T>() helper
  in api/migrations and api/batches endpoints (fix #3)
- Add SSE id: field to broadcastEvent and support Last-Event-ID header
  for automatic reconnection/replay (fix #8)
- Pass pre-fetched sourceCounts into MonitorConfig to skip redundant
  GitHub API call in runMonitor (fix #9)
- uploadArchive now accepts Blob | Uint8Array — callers pass Bun.file()
  (BunFile extends Blob) to stream from disk without loading the full
  archive into memory (fix #6)
- Replace `.buffer as ArrayBuffer` body with toBlob() normalizer that
  feeds fetch a proper Blob, eliminating the unsafe cast (fix #7)
Configure SvelteKit kit.csp to auto-inject nonces for script-src,
eliminating 'unsafe-inline' for scripts. style-src retains 'unsafe-inline'
because components use dynamic style= attributes (width: {pct}%).

Remove manual CSP header from hooks.server.ts — SvelteKit now manages
the Content-Security-Policy header on rendered pages. (fix #11)
- recoverOrphans .catch() now delegates directly to handlePipelineError
  instead of logging separately (removes duplicate console.error)
- Add comment explaining why event.id mutation in createEmitHandler is safe
…p, XFF spoofing, debounce leak

- handlePipelineResult: use || undefined so COALESCE preserves DB warningsCount
- startBatch: forward archiveSource into per-repo CreateMigrationRequest
- [id]/+page.svelte: track by event id instead of array length (broke after 501)
- hooks.server.ts: use getClientAddress() to prevent XFF rate-limit bypass
- migrations.svelte.ts: clear debounce timer in global SSE destroy()
…lure event

- resumeMigration: accept and forward AbortSignal to runMonitor so
  recovered migrations can actually be cancelled
- runMigrationPipeline: check signal.aborted independently of
  githubMigrationId so pre-GHEC cancellations get 'cancelled' state
- Only emit failure event for genuine errors, not user cancellations
  (prevents 'Unknown error' persisting on cancelled migrations)
When cancel() fires while runMonitor is mid-poll (during a network call
rather than during sleep), the abort signal doesn't cause poll to throw.
The while loop exits cleanly via its condition check, returning null for
terminalPhase. Without this guard, the caller falls through to marking
the migration as 'succeeded', overwriting the 'cancelled' state that
cancel() already wrote to the DB.

Changes:
- runMigrationPipeline: add null check after runMonitor — throws so the
  catch block properly handles cancellation vs unexpected exits
- resumeMigration: same null check, plus mirror the signal-abort logic
  from runMigrationPipeline in the catch block (was always setting
  'failed' even on user-initiated cancellation)
@cvega
cvega requested a review from Copilot February 15, 2026 07:57
@cvega
cvega merged commit 2fb1b66 into main Feb 15, 2026
7 checks passed
@cvega
cvega deleted the fix/code-review-16 branch February 15, 2026 08:03

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR improves the security posture and runtime robustness of the GitHub Migrate SvelteKit app, while also refactoring shared UI utilities and optimizing parts of the backend persistence/SSE pipeline.

Changes:

  • Add CSP configuration (moved from manual header) and harden auth rate-limiting IP handling.
  • Improve SSE resiliency by adding id: fields and supporting Last-Event-ID / ?after= replay.
  • Reduce memory usage and improve DB/query efficiency (stream archive uploads from disk, add indexes, aggregate batch list queries, safer JSON parsing), plus shared UI refactors (typed context keys, shared formatElapsed).

Reviewed changes

Copilot reviewed 22 out of 22 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
svelte.config.js Adds SvelteKit-managed CSP directives.
src/hooks.server.ts Hardens auth IP handling, rate-limit map eviction, and moves CSP handling to kit.csp; base64 decode via Buffer.
src/routes/api/migrations/[id]/events/+server.ts Adds Last-Event-ID support and emits id: lines during replay.
src/routes/api/migrations/+server.ts Uses narrowBody to avoid double-casts for validated request bodies.
src/routes/api/batches/+server.ts Same narrowBody refactor for batch POST body.
src/routes/[id]/+page.svelte Fixes SSE buffer wrap handling by tracking last processed event id; uses shared formatter.
src/routes/batches/[id]/+page.svelte Uses shared formatter instead of local implementation.
src/routes/+page.svelte Uses typed Symbol-based Svelte context key for GitHub status.
src/routes/+layout.svelte Sets typed Symbol-based context for GitHub status.
src/lib/stores/migrations.svelte.ts Ensures debounce timer is cleared on global SSE destroy.
src/lib/server/validate.ts Adds narrowBody<T>() helper to centralize the cast after validation.
src/lib/server/upload.ts Allows Blob/Uint8Array to enable disk streaming and multipart slicing.
src/lib/server/store.ts Adds safe JSON parsing, batch list aggregation query, and returns inserted event ids for SSE.
src/lib/server/schema.ts Adds DB indexes for migration state and batch_id.
src/lib/server/monitor.ts Allows passing pre-fetched source counts to avoid redundant API calls.
src/lib/server/migration.ts Streams archive upload inputs from disk; improves cancellation semantics; passes sourceCounts to monitor; adjusts resume signature.
src/lib/server/manager.ts DRYs emit/pipeline handlers; includes id: in SSE broadcast; uses aggregated batch pagination.
src/lib/format.ts New shared formatElapsed() utility for frontend/server.
src/lib/context-keys.ts New typed Symbol-based Svelte context keys.
src/lib/components/MigrationCard.svelte Uses shared formatter with custom fallback.
src/lib/components/FailureDetail.svelte Uses shared formatter.
README.md Major documentation refresh and expanded troubleshooting.
Comments suppressed due to low confidence (2)

src/lib/server/migration.ts:566

  • As with runMigrationPipeline, skipping failure emission on cancellation can leave SSE consumers without a terminal event and cause unnecessary reconnect loops / stale UI unless something else refreshes state. Consider emitting an explicit cancellation terminal event (or otherwise ensuring SSE clients can stop and refresh when cancelled).
    // Only emit failure events for genuine errors, not user cancellations.
    if (migration.state !== "cancelled") {
      emit({
        migrationId,
        eventType: "failure",

src/lib/server/migration.ts:201

  • runMonitor() returns a Phase string (e.g. "UNKNOWN" when exiting due to an abort), so this if (!terminalPhase) guard will never trigger. If the AbortSignal is set during an in-flight poll, runMonitor() can return "UNKNOWN" and the pipeline will continue and mark the migration as succeeded. Handle this explicitly (e.g., treat signal.aborted or terminalPhase === "UNKNOWN" as cancellation/error, or change runMonitor() to return Phase | null/throw on abort).
    // If the signal was aborted during poll() (not during sleep — which throws),
    // the monitor exits cleanly with null. Re-throw so the catch block handles
    // cancellation properly instead of falling through to "succeeded".
    if (!terminalPhase) {
      throw new Error(
        opts.signal?.aborted ? "Migration cancelled" : "Monitor exited without terminal state",
      );
    }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +261 to +264
// Only emit a failure event for genuine errors, not user cancellations.
if (migration.state !== "cancelled") {
emit({
migrationId,

Copilot AI Feb 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This catch block skips emitting any terminal event when a migration is cancelled. Because the SSE client stops reconnecting only on complete/failure events, cancellations can leave clients connected/retrying indefinitely and the UI may not refresh from SSE alone. Consider emitting a dedicated terminal event for cancellation (and teaching the client to treat it as terminal), or emitting a final failure/complete-like event that represents cancellation.

This issue also appears on line 562 of the same file.

Suggested change
// Only emit a failure event for genuine errors, not user cancellations.
if (migration.state !== "cancelled") {
emit({
migrationId,
// Always emit a terminal event so SSE clients can stop reconnecting.
if (migration.state === "cancelled") {
emit({
migrationId,
eventType: "cancelled",
phase: "CANCELLED",
payload: { reason: "Migration cancelled by user" },
createdAt: new Date().toISOString(),
});
} else {
emit({
migrationId,

Copilot uses AI. Check for mistakes.
Comment on lines +524 to +530
// Guard against monitor exiting without a terminal phase (e.g. signal
// aborted during a poll network call, not during sleep which throws).
if (!terminalPhase) {
throw new Error(
signal?.aborted ? "Migration cancelled" : "Monitor exited without terminal state",
);
}

Copilot AI Feb 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as above: if (!terminalPhase) will never fire because runMonitor() returns a non-empty Phase string. If signal is aborted during a poll, runMonitor() can return "UNKNOWN" and this code will incorrectly proceed to mark the migration as succeeded. Handle abort/"UNKNOWN" explicitly or update runMonitor() to return null/throw when it exits without a terminal phase.

This issue also appears on line 194 of the same file.

Copilot uses AI. Check for mistakes.
Comment thread src/lib/server/store.ts
Comment on lines +387 to 401
export function insertEvent(event: MigrationEvent): number {
const db = getDb();
db.prepare(
`INSERT INTO events (migration_id, event_type, phase, payload, created_at)
VALUES (?, ?, ?, ?, ?)`,
)
.run(
event.migrationId,
event.eventType,
event.phase,
JSON.stringify(event.payload),
event.createdAt,
);
).run(
event.migrationId,
event.eventType,
event.phase,
JSON.stringify(event.payload),
event.createdAt,
);
// Return the auto-increment row ID so callers can include it in SSE id: fields.
return (db.query("SELECT last_insert_rowid() AS id").get() as { id: number }).id;
}

Copilot AI Feb 15, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

insertEvent() now performs an extra SELECT last_insert_rowid() per event. That doubles DB round-trips on high-frequency event streams. If possible, capture the inserted row id from the insert statement itself (e.g., use the .run() return value if it exposes the last insert rowid, or use an INSERT ... RETURNING id pattern) to avoid the additional query.

See below for a potential fix:

  const result = db
    .prepare(
      `INSERT INTO events (migration_id, event_type, phase, payload, created_at)
			 VALUES (?, ?, ?, ?, ?)`,
    )
    .run(
      event.migrationId,
      event.eventType,
      event.phase,
      JSON.stringify(event.payload),
      event.createdAt,
    );
  // Return the auto-increment row ID so callers can include it in SSE id: fields.
  return result.lastInsertRowid as number;

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants