Skip to content

Unify media storage around Medium#286

Merged
dahlia merged 32 commits into
hackers-pub:mainfrom
dahlia:refactor/media
May 6, 2026
Merged

Unify media storage around Medium#286
dahlia merged 32 commits into
hackers-pub:mainfrom
dahlia:refactor/media

Conversation

@dahlia

@dahlia dahlia commented May 6, 2026

Copy link
Copy Markdown
Member

What changed

This PR replaces the split local media models with a shared Medium model and updates notes, articles, account avatars, GraphQL, legacy web, and web-next to use it.

The new Medium model is backed by FlyDrive object keys instead of storing image blobs in the database. It has a UUID primary key, nullable unique SHA-256 content hash, media type, optional image dimensions, and a check constraint that keeps width and height either both null or both present. Local uploads are normalized to WebP.

The migration in drizzle/0098_unified_medium.sql moves existing NoteMedium, ArticleMedium, and Account.avatarKey data into Medium plus the new relation tables. drizzle/0099_drop_note_source_medium_unique.sql relaxes note media attachment uniqueness so the same medium can appear more than once in a note.

Model and storage changes

  • Adds Medium and the relation tables NoteSourceMedium, ArticleDraftMedium, and ArticleSourceMedium.
  • Replaces Account.avatarKey with Account.avatarMediumId.
  • Stores per-note alt text on NoteSourceMedium, not on Medium.
  • Stores article image references through article medium relation keys used by hp-medium:KEY.
  • Resolves hp-medium: placeholders in legacy article pages, feeds, ActivityPub objects, drafts, and web-next article rendering.
  • Preserves avatar-specific crop/resize behavior before storing avatar media.
  • Adds manual orphan media cleanup for moderators through GraphQL and admin pages.

Key files include models/medium.ts, models/schema.ts, models/note.ts, models/article.ts, models/account.ts, graphql/post.ts, graphql/account.ts, graphql/medium-upload.ts, web/routes/admin/media.tsx, and web-next/src/routes/(root)/admin/media.tsx.

GraphQL API changes

This PR adds the Medium GraphQL type and new upload APIs. All upload mutations require authentication.

Medium

type Medium implements Node {
  id: ID!
  uuid: UUID!
  url: URL!
  type: MediaType!
  contentHash: Sha256
  width: Int
  height: Int
  created: DateTime!
}

Simple upload: createMedium

Use this when the client has a small image as a data URL or an HTTP(S) URL. The server fetches the image, validates it, normalizes it to WebP, stores it through FlyDrive, and returns the created Medium.

mutation CreateMedium($input: CreateMediumInput!) {
  createMedium(input: $input) {
    __typename
    ... on CreateMediumPayload {
      medium {
        uuid
        url
        type
        width
        height
        contentHash
      }
    }
    ... on InvalidInputError {
      inputPath
    }
    ... on NotAuthenticatedError {
      notAuthenticated
    }
  }
}

Example variables:

{
  "input": {
    "url": "data:image/png;base64,..."
  }
}

HTTP and HTTPS URLs are also accepted. The server-side fetch path is guarded against SSRF.

Streaming upload: startMediumUpload and finishMediumUpload

Use this flow for larger images. The GraphQL API creates an upload session and returns a PUT target. The client uploads bytes directly to that URL, then calls finishMediumUpload to validate and finalize the image as a Medium.

sequenceDiagram
  participant Client
  participant GraphQL
  participant UploadTarget as Signed URL or upload proxy
  participant Storage as FlyDrive
  Client->>GraphQL: startMediumUpload(contentType, contentLength)
  GraphQL->>GraphQL: Validate auth, type, and size
  GraphQL->>Storage: Create temporary upload key/session
  GraphQL-->>Client: uploadId, method, uploadUrl, headers, expires
  Client->>UploadTarget: PUT image bytes with returned headers and exact Content-Length
  UploadTarget->>Storage: Store temporary object
  Client->>GraphQL: finishMediumUpload(uploadId)
  GraphQL->>Storage: Read temporary object
  GraphQL->>GraphQL: Validate image and normalize to WebP
  GraphQL->>Storage: Store final medium object
  GraphQL->>Storage: Delete temporary object
  GraphQL-->>Client: Medium
Loading

Start mutation:

mutation StartMediumUpload($input: StartMediumUploadInput!) {
  startMediumUpload(input: $input) {
    __typename
    ... on StartMediumUploadPayload {
      uploadId
      uploadUrl
      method
      headers {
        name
        value
      }
      expires
    }
    ... on InvalidInputError {
      inputPath
    }
    ... on NotAuthenticatedError {
      notAuthenticated
    }
  }
}

Example variables:

{
  "input": {
    "contentType": "image/png",
    "contentLength": 1234567
  }
}

Upload step:

await fetch(uploadUrl, {
  method,
  headers: {
    ...Object.fromEntries(headers.map(({ name, value }) => [name, value])),
    "Content-Length": String(file.size)
  },
  body: file
});

Finish mutation:

mutation FinishMediumUpload($input: FinishMediumUploadInput!) {
  finishMediumUpload(input: $input) {
    __typename
    ... on FinishMediumUploadPayload {
      medium {
        uuid
        url
        type
        width
        height
        contentHash
      }
    }
    ... on InvalidInputError {
      inputPath
    }
    ... on NotAuthenticatedError {
      notAuthenticated
    }
  }
}

Example variables:

{
  "input": {
    "uploadId": "019dfc5d-0000-7000-8000-000000000000"
  }
}

Attaching media to notes

createNote now accepts a media argument. Alt text belongs to the note attachment, not to the shared Medium.

mutation CreateNote($input: CreateNoteInput!) {
  createNote(input: $input) {
    __typename
    ... on CreateNotePayload {
      note {
        id
        media {
          mediumId
          alt
          url
        }
      }
    }
  }
}

Example variables:

{
  "input": {
    "content": "hello",
    "language": "en",
    "visibility": "PUBLIC",
    "media": [
      {
        "mediumId": "019dfc5d-0000-7000-8000-000000000000",
        "alt": "Screenshot of the upload dialog"
      }
    ]
  }
}

Attaching media to article drafts

Article drafts use relation keys so Markdown can refer to images as hp-medium:KEY.

mutation AttachArticleDraftMedium($input: AttachArticleDraftMediumInput!) {
  attachArticleDraftMedium(input: $input) {
    __typename
    ... on AttachArticleDraftMediumPayload {
      key
      medium {
        uuid
        url
      }
    }
    ... on InvalidInputError {
      inputPath
    }
    ... on NotAuthenticatedError {
      notAuthenticated
    }
  }
}

The returned key can be used in article Markdown:

![Alt text](hp-medium:KEY)

Attaching media while editing articles

Published articles can also attach new media during updateArticle. This is the path used when an editor uploads a new image while editing an existing article. The uploaded Medium is passed in UpdateArticleInput.media, and article Markdown refers to it as hp-medium:KEY.

mutation UpdateArticle($input: UpdateArticleInput!) {
  updateArticle(input: $input) {
    __typename
    ... on UpdateArticlePayload {
      article {
        id
        content
      }
    }
    ... on InvalidInputError {
      inputPath
    }
    ... on NotAuthenticatedError {
      notAuthenticated
    }
  }
}

Example variables:

{
  "input": {
    "articleId": "QXJ0aWNsZTowMTlkZmM1ZC0wMDAwLTcwMDAtODAwMC0wMDAwMDAwMDAwMDA=",
    "content": "![Alt text](hp-medium:hero)",
    "media": [
      {
        "key": "hero",
        "mediumId": "019dfc5d-0000-7000-8000-000000000000"
      }
    ]
  }
}

key is optional and defaults to the medium UUID. The update validates the final Markdown before saving: every new hp-medium:KEY reference must either already be attached to the article source or be present in the media input. If a referenced medium is missing, the article update fails instead of publishing content with unresolved media placeholders.

Account avatars

UpdateAccountInput.avatarUrl is deprecated. New clients should upload a medium first, then pass avatarMediumId.

mutation UpdateAvatar($id: ID!, $avatarMediumId: UUID!) {
  updateAccount(input: { id: $id, avatarMediumId: $avatarMediumId }) {
    account {
      id
      avatarUrl
    }
  }
}

avatarUrl and avatarMediumId are mutually exclusive.

Orphan media cleanup

Because we do not have cron-style background jobs yet, this PR adds a moderator-only manual cleanup path.

GraphQL additions:

  • orphanMediaStatus
  • deleteOrphanMedia

The cleanup only deletes media older than the grace period and not referenced by accounts, notes, article drafts, or article sources. There are admin UI entry points in web/routes/admin/media.tsx and web-next/src/routes/(root)/admin/media.tsx.

Compatibility notes

  • uploadMedia() was removed from GraphQL because it could not remain backward compatible.
  • Existing avatar URL upload support remains in GraphQL as deprecated avatarUrl, but new clients should use createMedium or startMediumUpload/finishMediumUpload plus avatarMediumId.
  • Existing article and note media rows are migrated into the unified model.
  • Article Markdown using migrated media is rewritten to hp-medium: references and resolved where rendered or federated.

Tests

  • deno task check
  • deno task test
  • Added and updated model, GraphQL, migration, media upload, article, note, account, and admin cleanup tests.

dahlia added 12 commits May 6, 2026 02:26
Add the shared Medium model and migrate note, article, and account avatar
media to relation tables backed by FlyDrive keys.  Keep post media for
remote ActivityPub attachments separate.

Replace the old uploadMedia API with createMedium, streaming upload
session mutations, and article draft media attachment.  Update legacy web
and web-next avatar/article upload paths to use the new GraphQL/model
APIs.

Assisted-by: Codex:gpt-5.5
Remove an invalid ArticleDraft column selection from the contentHtml
resolver. The preview only needs the draft content and medium relation
data to render hp-medium references.

Assisted-by: Codex:gpt-5.5
Pass article source medium URLs into the legacy article renderer so
migrated hp-medium placeholders render as public image URLs.

Assisted-by: Codex:gpt-5.5
Let article creation persist referenced medium mappings before rendering the
post or sending ActivityPub Create. Both GraphQL and legacy draft publish
now pass draft media through the shared model path.

Assisted-by: Codex:gpt-5.5
Make avatarMedium a required nullable relation for actor sync inputs and
load it at note and session call sites. This prevents note refresh paths
from replacing uploaded avatar URLs with fallback images.

Assisted-by: Codex:gpt-5.5
Apply article media URL replacements recursively during the unified medium
migration. This converts every matching URL for multi-image drafts and
article contents instead of relying on UPDATE ... FROM row selection.

Assisted-by: Codex:gpt-5.5
Apply the existing avatar crop and size cap before storing avatar media
from legacy uploads, deprecated avatarUrl updates, and avatarMediumId
assignments. Add the missing Drizzle snapshot for the unified medium
migration so future schema diffs have the right baseline.

Assisted-by: Codex:gpt-5.5
Drop the per-note medium uniqueness constraint so the same deduplicated
Medium can appear at multiple indexes with distinct alt text. Add a
migration, snapshot, and regression test for repeated note attachments.

Assisted-by: Codex:gpt-5.5
Validate proxy upload Content-Length before reading the request body so
fallback uploads cannot buffer unbounded chunked data. Cover both the
missing-length rejection path and a valid exact-length upload.

Assisted-by: Codex:gpt-5.5
Rename GraphQL DateTime fields that used an At suffix to match the
schema convention.  Keep the internal model property names unchanged and
map them at the GraphQL boundary.

Assisted-by: Codex:gpt-5.5
Add a Sha256 GraphQL scalar for lowercase hex-encoded SHA-256 digests
and expose Medium.contentHash through it instead of String.  Update both
GraphQL codegen configurations so clients continue to generate cleanly.

Assisted-by: Codex:gpt-5.5
Add moderator-only orphan media status and deletion APIs, plus manual
cleanup pages in both web stacks.  The cleanup keeps a 24-hour grace
period and only deletes media that are no longer referenced by accounts,
notes, article drafts, or article sources.

Assisted-by: Codex:gpt-5.5
@dahlia dahlia self-assigned this May 6, 2026
@dahlia dahlia added the enhancement New feature or request label May 6, 2026
@dahlia

dahlia commented May 6, 2026

Copy link
Copy Markdown
Member Author

@codex review

@coderabbitai

coderabbitai Bot commented May 6, 2026

Copy link
Copy Markdown

Caution

Review failed

Failed to post review comments

📝 Walkthrough

Walkthrough

Implements a unified Medium subsystem: DB enum/table and join tables; migrates existing media and avatars into Mediums; adds media creation/upload APIs (streaming session + proxy), GraphQL types/mutations/scalar, orphan-media admin flows, model wiring, frontend integration, and extensive tests and translations.

Changes

Unified Medium System

Layer / File(s) Summary
DB Types & Schema
drizzle/0098_unified_medium.sql, drizzle/0099_drop_note_source_medium_unique.sql, drizzle/meta/_journal.json
Adds public.medium_type enum and public.medium table; join tables note_source_medium, article_draft_medium, article_source_medium; adds account.avatar_medium_id; migrates legacy note_medium/article_medium/avatar_key; rewrites article contents to hp-medium: keys; journal updated.
ORM Schema & Relations
models/schema.ts, models/relations.ts
Exports new Medium types/tables and join tables; removes legacy media tables; adds avatarMediumId field and avatarMedium relation on account; updates relations for articles/notes/drafts.
Core Medium Implementation
models/medium.ts, models/medium.test.ts
Adds media ingestion utilities: create from bytes/blob/URL, streaming limits, SSR-safe URL checks, sha256 dedupe, image processing (sharp), disk storage, upsert helpers, error type UnsafeMediumUrlError, and tests.
Avatar Helpers & Account Integration
models/account.ts, graphql/account.ts, models/account.more.test.ts
Adds createAvatarMediumFromBlob/Url/Medium, updates account queries and updateAccount to accept avatarMediumId, exposes avatarMedium in actor sync and GraphQL selections, removes legacy disk transform path.
Model Integration (Notes/Articles/Posts/Markup)
models/note.ts, models/article.ts, models/post.ts, models/markup.ts, models/*.{lifecycle,test}.ts
Notes/articles use join-table media (NoteSourceMedium, ArticleDraft/SourceMedium); renderMarkup accepts mediumUrls and resolves hp-medium: tokens; article/article-draft helpers to fetch medium URLs; post sync flows updated; many lifecycle tests added.
GraphQL API, Upload Proxy & Scalars
graphql/schema.graphql, graphql/post.ts, graphql/medium-upload.ts, graphql/medium-upload.test.ts, graphql/builder.ts
Adds Medium GraphQL type, Sha256 scalar, mutations createMedium, startMediumUpload, finishMediumUpload, attachArticleDraftMedium; implements TTL upload session store and HTTP proxy handleMediumUploadProxy; tests for proxy and upload flows.
GraphQL Wiring & Server
graphql/server.ts, graphql/main.ts, federation/actor.ts, federation/objects.ts
Preloads avatarMedium and media relations, invokes upload proxy early in request handling, and passes mediumUrls into content rendering and federation actor payloads.
Admin Orphan Media
models/admin.ts, graphql/admin.ts, models/admin.test.ts, graphql/admin.test.ts
Adds orphan-media reporting and deletion (getOrphanMediaStatus, deleteOrphanMedia) with grace period, disk deletion concurrency, GraphQL query/mutation, admin mutation and UI wiring, and tests.
Frontend Integration & Routes
web-next/src/lib/uploadImage.ts, web-next/src/routes/(root)/settings/index.tsx, web/routes/@[username]/settings/index.tsx, web/routes/api/media.ts, web/main.ts, various web/* routes
Client flows updated to use Mediums (createMedium, attachArticleDraftMedium, avatarMediumId) across settings, article composer, draft publish, API media endpoint; server middleware plugs upload proxy; test disk and upload helpers updated.
UI, Nav & Localization
web-next/src/components/AppSidebar.tsx, web/components/AdminNav.tsx, web-next/src/routes/(root)/admin/media.tsx, web-next/src/locales/*
Adds Admin → Media navigation and admin media page; extensive localization and glossary updates for media/avatar/orphan text.
Tests & Test Harness
test/postgres.ts, graphql/*, models/*, web-next/*
Large test additions and updates covering medium creation/dedupe, upload proxy, attaching media to drafts/articles/notes, avatar medium flows, orphan-media deletion; in-memory test disk rewritten to support binary IO.

Sequence Diagram(s)

sequenceDiagram
    actor User
    participant Client as Web Client
    participant GraphQL as GraphQL Server
    participant DB as Database
    participant Disk as Disk Storage

    User->>Client: Upload avatar image / crop
    Client->>GraphQL: createMedium(dataUrl) or startMediumUpload()
    GraphQL->>Disk: write preprocessed bytes
    Disk-->>GraphQL: key/url
    GraphQL->>DB: insert/get Medium (key,type,hash,dims)
    DB-->>GraphQL: Medium id/uuid
    GraphQL-->>Client: medium payload { uuid, url, contentHash, width, height }
    Client->>GraphQL: updateAccount(avatarMediumId)
    GraphQL->>DB: update account.avatar_medium_id
    DB-->>GraphQL: updated account
    GraphQL->>GraphQL: syncActorFromAccount with avatarMedium
    GraphQL-->>Client: updated account/actor
Loading
sequenceDiagram
    actor Author
    participant Client as Web Client
    participant GraphQL as GraphQL Server
    participant DB as Database
    participant Disk as Disk Storage

    Author->>Client: Create article with remote media
    Client->>GraphQL: startMediumUpload(contentType, contentLength)
    GraphQL->>DB: create upload session (TTL)
    DB-->>GraphQL: uploadId, headers
    Client->>GraphQL: PUT upload bytes -> handleMediumUploadProxy
    GraphQL->>Disk: store session bytes
    GraphQL-->>Client: 204
    Client->>GraphQL: finishMediumUpload(uploadId)
    GraphQL->>Disk: read bytes, preprocess, store
    GraphQL->>DB: insert/get Medium
    DB-->>GraphQL: Medium id
    GraphQL->>DB: attach medium to draft via article_draft_medium
    GraphQL-->>Client: draft saved with medium mapping
Loading
sequenceDiagram
    actor Admin
    participant UI as Admin UI
    participant GraphQL as GraphQL Server
    participant DB as Database
    participant Disk as Disk Storage

    Admin->>UI: open /admin/media
    UI->>GraphQL: orphanMediaStatus
    GraphQL->>DB: SELECT mediums with no refs older than cutoff
    DB-->>GraphQL: count, cutoffDate
    GraphQL-->>UI: status
    Admin->>UI: click Delete Orphan Media
    UI->>GraphQL: deleteOrphanMedia
    GraphQL->>DB: SELECT orphan mediums
    DB-->>GraphQL: list(keys)
    loop per medium
      GraphQL->>Disk: delete file by key
      Disk-->>GraphQL: success or error
    end
    GraphQL->>DB: DELETE orphan medium rows
    GraphQL-->>UI: deletion results (deletedCount, failedDiskDeletes)
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~75 minutes

Possibly related PRs

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a unified media management system, centralizing image handling for avatars, notes, and articles into a new medium table. The implementation includes database migrations, model refactoring, and new GraphQL mutations for media uploads and attachments, supported by a new upload proxy and an admin maintenance page for orphan media. Feedback indicates that the InvalidInputError class is used throughout graphql/post.ts without being imported, which will lead to errors.

Comment thread graphql/post.ts Outdated
Comment thread graphql/post.ts
Comment thread graphql/post.ts Outdated
@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. 🚀

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@coderabbitai coderabbitai Bot 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.

Actionable comments posted: 12

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (4)
test/postgres.ts (1)

314-325: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

createTestDisk() stub — delete() doesn't accept a key and doesn't remove files.

The in-memory disk stub's delete() method diverges from the Disk contract. All production calls pass a key argument (disk.delete(key) in web/og.ts:87, graphql/post.ts:2210, models/admin.ts:316), but the test stub ignores it and doesn't remove anything from the files map. Tests exercising orphan-media cleanup will think a file was deleted but a subsequent getBytes(key) would still return the bytes, hiding real bugs in cleanup paths.

(Note: getBytes behavior is correct — it throws on missing keys, matching flydrive's documented contract.)

Fix: Accept key and actually remove from map
     put(key: string, contents: Uint8Array) {
       files.set(key, contents);
       return Promise.resolve(undefined);
     },
-    delete() {
+    delete(key: string) {
+      files.delete(key);
       return Promise.resolve(undefined);
     },
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/postgres.ts` around lines 314 - 325, The in-memory disk stub created by
createTestDisk() has a delete() method that currently takes no arguments and
doesn't remove entries from the files map; update delete() to accept a key
parameter (key: string) and call files.delete(key) before resolving so it
matches the Disk contract used by disk.delete(key) callers (see getBytes and put
for existing behaviors) and ensures getBytes(key) will throw after deletion.
federation/objects.ts (1)

57-64: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Don't emit raw hp-medium: placeholders in Article.source.

renderMarkup(..., { mediumUrls }) fixes the rendered HTML, but Source.content still comes from the original markdown, and the ...content spread after renderMarkup(...) would overwrite any transformed source text returned there. ActivityPub consumers that read source.content will still see unresolved hp-medium: tokens.

Also applies to: 102-105

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@federation/objects.ts` around lines 57 - 64, The mapping over
articleSource.contents currently spreads the original content object after
awaiting renderMarkup, so any transformed source text (resolved from hp-medium:
by renderMarkup) gets overwritten and raw hp-medium: tokens remain in
Article.source.content; change the merge order so the properties returned by
await renderMarkup(ctx, content.content, { docId: articleSource.id, kv:
ctx.data.kv, mediumUrls }) take precedence (or explicitly set the source/content
field from the render result) instead of being overwritten by ...content, and
apply the same fix to the similar block referenced at lines 102-105; locate this
in the articleSource.contents map and ensure the final object uses the rendered
content value for source/content.
models/post.ts (1)

349-358: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Use the stored medium MIME type here.

Medium.type can now be image/gif, image/jpeg, image/png, or image/webp, but this path rewrites every mirrored note attachment to image/webp. That mislabels migrated/imported media and can break consumers that key behavior off the attachment type.

Proposed fix
       await Promise.all(noteSource.media.map(async (medium) => ({
         postId: post.id,
         index: medium.index,
-        type: "image/webp" as const,
+        type: medium.medium.type,
         url: await disk.getUrl(medium.medium.key),
         alt: medium.alt,
         width: medium.medium.width,
         height: medium.medium.height,
       }))),
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@models/post.ts` around lines 349 - 358, The insert into postMediumTable
currently hardcodes type to "image/webp"; change it to use the stored
medium.type from noteSource.media (e.g., medium.type) when building the object
for insertion, and optionally validate or fallback if medium.type is
missing/unsupported (keep disk.getUrl(medium.medium.key), postId, index, alt,
width, height as-is). Update the mapping that builds the values (the Promise.all
over noteSource.media) so the inserted record's type field reflects Medium.type
instead of the literal "image/webp".
graphql/schema.graphql (1)

658-675: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Keep the old *At fields as deprecated aliases.

Renaming lastRegeneratedAtlastRegenerated and regeneratedAtregenerated removes fields from the public schema, which will break existing queries and persisted operations. Add the new names, but keep the old ones temporarily with @deprecated so clients can migrate safely.

Suggested schema shape
 type InvitationRegenerationStatus {
+  lastRegeneratedAt: DateTime `@deprecated`(reason: "Use lastRegenerated instead.")
   lastRegenerated: DateTime
 }

 type RegenerateInvitationsPayload {
+  regeneratedAt: DateTime! `@deprecated`(reason: "Use regenerated instead.")
   regenerated: DateTime!
 }

Also applies to: 1468-1476

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@graphql/schema.graphql` around lines 658 - 675, The schema removed legacy
"*At" fields which breaks existing clients; add back deprecated alias fields
alongside the new names: in type InvitationRegenerationStatus keep the new
fields cutoffDate, eligibleAccountsCount, lastRegenerated (DateTime), and
topThirdCount, and add deprecated aliases lastRegeneratedAt: DateTime
`@deprecated`(reason: "Use `lastRegenerated`") and regeneratedAt or regenerated?
(match the other type's rename) as deprecated aliases for the corresponding new
fields; do the same for the other occurrence referenced (lines ~1468-1476) so
both types expose the new field names plus the old "*At" fields marked with
`@deprecated` and identical types/descriptions to allow a migration path for
clients.
🧹 Nitpick comments (8)
web-next/src/locales/zh-CN/messages.po (1)

1750-1752: 💤 Low value

Slightly unnatural phrasing — "已足够旧" is a direct calque of "old enough"

"已足够旧" reads as awkward in Chinese. Consider a more idiomatic rephrasing:

✏️ Suggested revision
-msgstr "移除已足够旧且不再附加到头像、帖子、文章草稿或文章的已存储媒体。"
+msgstr "移除已超过保留期且不再附加到头像、帖子、文章草稿或文章的媒体。"
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-next/src/locales/zh-CN/messages.po` around lines 1750 - 1752, The Chinese
translation for the msgid in src/routes/(root)/admin/media.tsx:163 uses an
awkward literal "已足够旧"; update the corresponding msgstr to a more idiomatic
phrase such as replacing "已足够旧且不再附加到头像、帖子、文章草稿或文章的已存储媒体。" with a clearer wording
like "移除已过期且不再附着于头像、帖子、文章草稿或文章的已存储媒体。" — edit the msgstr for that msgid in
messages.po to use "已过期" (or another natural alternative) and keep the rest of
the sentence structure intact.
web-next/src/routes/(root)/[handle]/settings/index.tsx (1)

283-287: ⚡ Quick win

Use LogTape in the new avatar upload failure path.

This branch adds another raw console.error, which loses the structured context the rest of web-next expects for production debugging. Please route this through LogTape and include the save context instead.

As per coding guidelines, use structured logging via LogTape and include context in error details.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web-next/src/routes/`(root)/[handle]/settings/index.tsx around lines 283 -
287, The catch block that currently calls console.error(error) after
createMediumFromDataUrl(pendingAvatarUrl) should use the project's structured
logger LogTape and include the current save context; replace the raw
console.error with a LogTape error call that logs the caught error plus
structured fields such as the save object/identifier and pendingAvatarUrl so the
failure in createMediumFromDataUrl and the avatar upload (avatarMediumId) path
is recorded with proper context.
models/account.ts (1)

125-147: ⚡ Quick win

Expose avatarMedium in getAccountByUsername()’s return type.

Both queries now load avatarMedium, but the explicit return type still erases it. That makes the typed API drift from the actual result shape and forces downstream casts if callers need the new relation.

Possible fix
 ): Promise<
   | Account & {
       actor: Actor & { successor: Actor | null };
+      avatarMedium: Medium | null;
       emails: AccountEmail[];
       links: AccountLink[];
     }
   | undefined
 > {

As per coding guidelines, use explicit typing for complex return types in TypeScript.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@models/account.ts` around lines 125 - 147, The function getAccountByUsername
currently queries avatarMedium but its explicit return type omits that relation;
update getAccountByUsername's declared return type to include the avatarMedium
relation (matching the type returned by db.query.accountTable.findFirst with
with: { avatarMedium: true }) so callers get the correct shape without casts;
locate getAccountByUsername in models/account.ts and extend its return/interface
to include avatarMedium (nullable if DB allows null) or use the generated query
result type that includes with: { avatarMedium: true } for accuracy.
drizzle/0098_unified_medium.sql (1)

30-58: ⚡ Quick win

Add indexes for the new foreign-key columns.

PostgreSQL does not auto-index FKs. The orphan-media status/delete paths and any reverse lookups from medium will otherwise scan account, note_source_medium, article_draft_medium, and article_source_medium.

Suggested indexes
 CREATE TABLE IF NOT EXISTS "article_source_medium" (
   "article_source_id" uuid NOT NULL,
   "key" text NOT NULL,
   "medium_id" uuid NOT NULL,
   "created" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
   CONSTRAINT "article_source_medium_article_source_id_key_pk"
     PRIMARY KEY("article_source_id","key")
 );
 --> statement-breakpoint
 ALTER TABLE "account" ADD COLUMN "avatar_medium_id" uuid;
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "account_avatar_medium_id_idx"
+  ON "account" ("avatar_medium_id");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "note_source_medium_medium_id_idx"
+  ON "note_source_medium" ("medium_id");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "article_draft_medium_medium_id_idx"
+  ON "article_draft_medium" ("medium_id");
+--> statement-breakpoint
+CREATE INDEX IF NOT EXISTS "article_source_medium_medium_id_idx"
+  ON "article_source_medium" ("medium_id");
 --> statement-breakpoint
 DO $$ BEGIN

Also applies to: 60-123

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@drizzle/0098_unified_medium.sql` around lines 30 - 58, Add b-tree indexes on
the new foreign-key columns to avoid full-table scans: create an index on
note_source_medium("medium_id"), on article_draft_medium("medium_id"), and on
article_source_medium("medium_id"); if there is a medium_id FK on account, add
an index on account("medium_id") as well. Use clear, unique names (e.g.
note_source_medium_medium_id_idx, article_draft_medium_medium_id_idx,
article_source_medium_medium_id_idx, account_medium_id_idx) and create them
alongside the CREATE TABLE statements so reverse lookups and orphan-media/delete
paths use the index.
web/routes/_app.tsx (1)

73-78: ⚡ Quick win

Hoist Medium to the existing type import block.

The inline import("@hackerspub/models/schema").Medium is inconsistent with the surrounding style — Account and AccountEmail are already imported from the same module via a top-level type import. Pulling Medium up makes the local type alias easier to read.

♻️ Proposed import hoist
 import {
   type Account,
   type AccountEmail,
   articleDraftTable,
+  type Medium,
 } from "@hackerspub/models/schema";
-  let account:
-    | Account & {
-      emails: AccountEmail[];
-      avatarMedium?: import("@hackerspub/models/schema").Medium | null;
-    }
-    | undefined = undefined;
+  let account:
+    | Account & {
+      emails: AccountEmail[];
+      avatarMedium?: Medium | null;
+    }
+    | undefined = undefined;

As per coding guidelines: "Use type keyword for type imports when appropriate in TypeScript".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/routes/_app.tsx` around lines 73 - 78, The inline import for Medium
should be hoisted to the existing top-level type imports: add Medium to the
existing type import list that already includes Account and AccountEmail, then
update the local variable type annotation for account (the union type using
Account & { emails: AccountEmail[]; avatarMedium?:
import("@hackerspub/models/schema").Medium | null; }) to reference Medium
directly (avatarMedium?: Medium | null). Modify the top-level type import and
the account declaration to use the imported Medium symbol.
graphql/medium-upload.test.ts (1)

43-74: ⚡ Quick win

Consider adding a Content-Length mismatch test.

The two paths covered (missing + exact match) leave a gap: a declared Content-Length that disagrees with the actual byte count (either short-write or over-read). If handleMediumUploadProxy enforces the declared length, a regression there would silently slip through. Worth one more test asserting the rejected status when the body length differs from the header.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@graphql/medium-upload.test.ts` around lines 43 - 74, Add a new test in
graphql/medium-upload.test.ts that exercises handleMediumUploadProxy with a
declared "Content-Length" that does not match the actual body size: create a
session via createMediumUploadSession, send a PUT Request to
`/medium-uploads/${session.id}?token=${session.token}` with headers
"Content-Type" set and "Content-Length" set to a value different from the bytes
supplied (both smaller and/or larger cases if desired), then assert the response
indicates rejection (e.g., non-204 status such as 400/413) and verify that
disk.getBytes(session.key) was not written; this will catch regressions where
declared length is not enforced.
models/admin.test.ts (1)

31-42: 💤 Low value

Stub disk via the cast escape hatch only catches delete().

createTrackingDisk uses unknown as Parameters<typeof deleteOrphanMedia>[1] to satisfy the second argument's type. If deleteOrphanMedia ever starts calling another method on the disk (e.g., exists, getBytes), this stub will throw at runtime instead of being caught by the type system. Consider either implementing the full Disk shape or using a Partial<Disk> cast that documents the intent — minor, but worth noting since the cast is the only place the test contract is enforced.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@models/admin.test.ts` around lines 31 - 42, createTrackingDisk currently
casts a minimal stub to Parameters<typeof deleteOrphanMedia>[1] using `unknown
as ...` which only implements delete() and will break if deleteOrphanMedia calls
other Disk methods; replace this unsafe cast by returning either a proper
Disk-shaped object that implements all methods deleteOrphanMedia might call
(e.g., delete, exists, getBytes, etc.) or explicitly cast to Partial<Disk>
(e.g., `as Partial<Disk>`) and implement/document each stubbed method; update
createTrackingDisk to expose deleteKeys and implement any additional methods
referenced by deleteOrphanMedia so the test remains type-safe and fails at
compile time if the contract changes.
graphql/schema.graphql (1)

520-521: ⚡ Quick win

Use a storage-agnostic name for the new failure counter.

This PR moves media to FlyDrive/object keys, so a brand-new public field named failedDiskDeletes is already out of date and leaks an implementation detail into the API. A neutral name like failedStorageDeletes will age much better.

Suggested rename
-  """Number of stored media objects that could not be deleted from disk."""
-  failedDiskDeletes: Int!
+  """Number of stored media objects that could not be deleted from storage."""
+  failedStorageDeletes: Int!
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@graphql/schema.graphql` around lines 520 - 521, The public GraphQL field
failedDiskDeletes leaks an implementation detail; rename it to a
storage-agnostic failedStorageDeletes across the schema (graphql/schema.graphql)
and update all corresponding resolver functions, type definitions, tests, and
any client/query usages to the new symbol so the API remains neutral to backend
storage changes. Ensure the resolver that previously returned failedDiskDeletes
now returns failedStorageDeletes (preserve semantics and type Int!), update any
documentation and migration notes, and run tests to catch remaining references.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@drizzle/0098_unified_medium.sql`:
- Around line 134-149: The INSERT uses DISTINCT ON ("key") without ORDER BY so
PostgreSQL picks an arbitrary row per key; make it deterministic by adding an
ORDER BY that specifies which legacy row to prefer (e.g., choose the most recent
article_medium row). Modify the SELECT to include ORDER BY "key", "created" DESC
(or whichever deterministic tie-breaker you want) so DISTINCT ON("key")
consistently picks the same row and ensures medium.created is stable; this
change should be applied to the INSERT ... SELECT from "article_medium" that
inserts into "medium".

In `@graphql/medium-upload.ts`:
- Around line 100-118: The code currently trusts Content-Length and uses
request.arrayBuffer(), which can let an attacker cause the server to allocate a
large buffer; change the read to a capped streaming read instead: replace the
call to request.arrayBuffer() in the upload handler with a streaming reader
(e.g. request.body.getReader()) that accumulates chunks into a
Uint8Array/temporary buffer but stops and returns a 413 immediately if the
accumulated byte count exceeds Math.min(session.contentLength,
MAX_STREAMING_MEDIUM_IMAGE_SIZE) or differs from session.contentLength when
finished; keep the existing checks against session.contentLength and
MAX_STREAMING_MEDIUM_IMAGE_SIZE, but enforce the cap while reading to avoid
buffering oversized bodies.

In `@graphql/post.ts`:
- Around line 2069-2080: The catch block in the createMediumFromUrl call is
incorrectly mapping all failures to InvalidInputError; change it so only true
validation/formatting failures become InvalidInputError (e.g., when
createMediumFromUrl returns null or throws a known validation error type), but
let storage/network/DB errors bubble up or be converted to a 5xx/internal error
instead. Concretely, update the try/catch in the code around createMediumFromUrl
(and the similar block in finishMediumUpload that interacts with
createMediumFromBytes) to: check the return value (if medium === null) and throw
InvalidInputError("url"); in the catch, inspect the thrown error and rethrow it
unless it is a validation-specific error type, or wrap unexpected errors in an
internal/server error so successful writes are not reported as invalid input.
Ensure you reference createMediumFromUrl, finishMediumUpload, and
createMediumFromBytes when making these changes.

In `@graphql/schema.graphql`:
- Around line 1812-1814: The schema's write-side migration added
UpdateAccountInput.avatarMediumId but the Account output type still only exposes
avatarUrl; update the read side by adding avatarMediumId to the Account type and
mark avatarUrl as deprecated (with a reason pointing to avatarMediumId) so the
new field is discoverable via introspection; also ensure any resolvers for
Account (e.g., account resolver or Account type resolvers) return avatarMediumId
from the underlying model/DB (falling back to avatarUrl if needed) so both
schema and runtime behavior are aligned.

In `@models/admin.ts`:
- Around line 308-323: deleteOrphanMedia currently deletes medium rows first
then calls disk.delete, which makes failed disk deletions unrecoverable; change
the flow to fetch candidate keys (using orphanMediaWhere) first, attempt
disk.delete for each key and collect successes, then delete only the DB rows for
keys successfully removed (or mark failures for retry). Specifically: in
deleteOrphanMedia (use mediumTable, orphanMediaWhere, and disk.delete), query
keys to delete, loop attempting disk.delete and build a successKeys array and
increment failedDiskDeletes for failures (logging warnings), then run
db.delete(mediumTable).where(key in successKeys).returning(...) to remove only
the successfully deleted entries so failed deletes remain retryable.

In `@models/article.ts`:
- Around line 300-310: The substring check using
source.content.includes(`hp-medium:${medium.key}`) can false‑positive on
prefixes; update the logic that builds media (using sourceMedia, source.content,
articleSource.id and inserting into articleSourceMediumTable) to first extract
actual referenced keys from source.content (e.g., run a single global regex
similar to resolveMediumUrls's character class to capture full hp-medium:KEY
tokens or split on token boundaries), build a Set of exact keys, then filter
sourceMedia by membership in that Set so only exact key matches are inserted
(keep the insert into articleSourceMediumTable and .onConflictDoNothing()
unchanged).

In `@models/medium.ts`:
- Around line 135-152: The sharp image decoding/processing block can throw on
corrupt bytes or mismatched Content-Type, so wrap the call that creates and
processes the image (the sharp(...).rotate().webp().toBuffer({...}) sequence
used in createMediumFromBytes()/createMediumFromUrl()) in a try/catch and treat
any thrown error as an invalid medium by returning undefined; keep subsequent
checks (width/height/contentHash) the same and only proceed on success, and do
not rethrow the sharp error so bad uploads fail validation instead of causing a
server error.
- Around line 215-221: The code currently calls response.blob() and only checks
blob.size after the full body is downloaded, which can OOM if Content-Length is
missing/dishonest; instead, read response.body as a stream (use the
ReadableStream reader from response.body) and accumulate bytes up to maxSize,
aborting/canceling the reader and returning undefined as soon as accumulated >
maxSize; replace the blob() call and subsequent blob.size check with this
streaming read so contentLength and maxSize are enforced eagerly (refer to
variables contentLength, maxSize, response, response.body, and the existing
response.blob() usage to locate the change).

In `@models/note.ts`:
- Around line 288-291: The note creation must fail if an attachment can't be
persisted: in createNote() (and the analogous block at lines referenced 327-338)
stop the workflow when createMediumFromBlob/createNoteSourceMedium or the medium
lookup returns null/undefined by aborting note creation immediately (return
undefined or throw) instead of continuing and publishing a note without the
attachment; update the code paths around the medium = "blob" in input ? await
createMediumFromBlob(...) : await db.query.mediumTable.findFirst(...) checks so
that medium == null causes an immediate failure return and does not proceed to
publishing or saving the note.

In `@web-next/src/locales/zh-TW/messages.po`:
- Around line 21-24: The PR introduces the terms "孤立媒體" (orphan media) and
"磁碟物件" (disk object) in the zh-TW locale messages (e.g., the plural msgid string
for failed deletions) but they are missing from the zh-TW glossary; update the
glossary to include entries mapping "orphan media → 孤立媒體" and "disk object →
磁碟物件" (or replace the occurrences in the affected messages with the existing
glossary-approved terms if they already exist), and then ensure the translations
in messages.po (the plural msgid and other affected message strings) use those
glossary terms consistently.

In `@web-next/src/routes/`(root)/admin/media.tsx:
- Around line 146-150: The Show conditional using when={data.viewer?.moderator}
treats both unauthenticated users and authenticated non-moderators the same,
causing signed-in non-moderators to be redirected to sign-in; change the
fallback so it checks data.viewer !== null first and: if viewer is null Navigate
to "/sign?next=%2Fadmin%2Fmedia", otherwise render a forbidden message or
Navigate to "/" (adjust the Show fallback that currently returns <Navigate ...
/>), referencing the Show component, data.viewer and the current fallback
Navigate to locate and update the logic.

In `@web/routes/`@[username]/settings/index.tsx:
- Around line 61-65: The POST handler is missing an ownership/session check
allowing cross-account updates; before applying updates (after obtaining account
via db.query.accountTable.findFirst using ctx.params.username) verify
ctx.state.session exists and that account.id === ctx.state.session.accountId,
and if not return ctx.next() or respond with a 403/permission error, ensuring
the same session-based guard used by the GET path is applied to the POST flow to
prevent unauthorized mutations.

---

Outside diff comments:
In `@federation/objects.ts`:
- Around line 57-64: The mapping over articleSource.contents currently spreads
the original content object after awaiting renderMarkup, so any transformed
source text (resolved from hp-medium: by renderMarkup) gets overwritten and raw
hp-medium: tokens remain in Article.source.content; change the merge order so
the properties returned by await renderMarkup(ctx, content.content, { docId:
articleSource.id, kv: ctx.data.kv, mediumUrls }) take precedence (or explicitly
set the source/content field from the render result) instead of being
overwritten by ...content, and apply the same fix to the similar block
referenced at lines 102-105; locate this in the articleSource.contents map and
ensure the final object uses the rendered content value for source/content.

In `@graphql/schema.graphql`:
- Around line 658-675: The schema removed legacy "*At" fields which breaks
existing clients; add back deprecated alias fields alongside the new names: in
type InvitationRegenerationStatus keep the new fields cutoffDate,
eligibleAccountsCount, lastRegenerated (DateTime), and topThirdCount, and add
deprecated aliases lastRegeneratedAt: DateTime `@deprecated`(reason: "Use
`lastRegenerated`") and regeneratedAt or regenerated? (match the other type's
rename) as deprecated aliases for the corresponding new fields; do the same for
the other occurrence referenced (lines ~1468-1476) so both types expose the new
field names plus the old "*At" fields marked with `@deprecated` and identical
types/descriptions to allow a migration path for clients.

In `@models/post.ts`:
- Around line 349-358: The insert into postMediumTable currently hardcodes type
to "image/webp"; change it to use the stored medium.type from noteSource.media
(e.g., medium.type) when building the object for insertion, and optionally
validate or fallback if medium.type is missing/unsupported (keep
disk.getUrl(medium.medium.key), postId, index, alt, width, height as-is). Update
the mapping that builds the values (the Promise.all over noteSource.media) so
the inserted record's type field reflects Medium.type instead of the literal
"image/webp".

In `@test/postgres.ts`:
- Around line 314-325: The in-memory disk stub created by createTestDisk() has a
delete() method that currently takes no arguments and doesn't remove entries
from the files map; update delete() to accept a key parameter (key: string) and
call files.delete(key) before resolving so it matches the Disk contract used by
disk.delete(key) callers (see getBytes and put for existing behaviors) and
ensures getBytes(key) will throw after deletion.

---

Nitpick comments:
In `@drizzle/0098_unified_medium.sql`:
- Around line 30-58: Add b-tree indexes on the new foreign-key columns to avoid
full-table scans: create an index on note_source_medium("medium_id"), on
article_draft_medium("medium_id"), and on article_source_medium("medium_id"); if
there is a medium_id FK on account, add an index on account("medium_id") as
well. Use clear, unique names (e.g. note_source_medium_medium_id_idx,
article_draft_medium_medium_id_idx, article_source_medium_medium_id_idx,
account_medium_id_idx) and create them alongside the CREATE TABLE statements so
reverse lookups and orphan-media/delete paths use the index.

In `@graphql/medium-upload.test.ts`:
- Around line 43-74: Add a new test in graphql/medium-upload.test.ts that
exercises handleMediumUploadProxy with a declared "Content-Length" that does not
match the actual body size: create a session via createMediumUploadSession, send
a PUT Request to `/medium-uploads/${session.id}?token=${session.token}` with
headers "Content-Type" set and "Content-Length" set to a value different from
the bytes supplied (both smaller and/or larger cases if desired), then assert
the response indicates rejection (e.g., non-204 status such as 400/413) and
verify that disk.getBytes(session.key) was not written; this will catch
regressions where declared length is not enforced.

In `@graphql/schema.graphql`:
- Around line 520-521: The public GraphQL field failedDiskDeletes leaks an
implementation detail; rename it to a storage-agnostic failedStorageDeletes
across the schema (graphql/schema.graphql) and update all corresponding resolver
functions, type definitions, tests, and any client/query usages to the new
symbol so the API remains neutral to backend storage changes. Ensure the
resolver that previously returned failedDiskDeletes now returns
failedStorageDeletes (preserve semantics and type Int!), update any
documentation and migration notes, and run tests to catch remaining references.

In `@models/account.ts`:
- Around line 125-147: The function getAccountByUsername currently queries
avatarMedium but its explicit return type omits that relation; update
getAccountByUsername's declared return type to include the avatarMedium relation
(matching the type returned by db.query.accountTable.findFirst with with: {
avatarMedium: true }) so callers get the correct shape without casts; locate
getAccountByUsername in models/account.ts and extend its return/interface to
include avatarMedium (nullable if DB allows null) or use the generated query
result type that includes with: { avatarMedium: true } for accuracy.

In `@models/admin.test.ts`:
- Around line 31-42: createTrackingDisk currently casts a minimal stub to
Parameters<typeof deleteOrphanMedia>[1] using `unknown as ...` which only
implements delete() and will break if deleteOrphanMedia calls other Disk
methods; replace this unsafe cast by returning either a proper Disk-shaped
object that implements all methods deleteOrphanMedia might call (e.g., delete,
exists, getBytes, etc.) or explicitly cast to Partial<Disk> (e.g., `as
Partial<Disk>`) and implement/document each stubbed method; update
createTrackingDisk to expose deleteKeys and implement any additional methods
referenced by deleteOrphanMedia so the test remains type-safe and fails at
compile time if the contract changes.

In `@web-next/src/locales/zh-CN/messages.po`:
- Around line 1750-1752: The Chinese translation for the msgid in
src/routes/(root)/admin/media.tsx:163 uses an awkward literal "已足够旧"; update the
corresponding msgstr to a more idiomatic phrase such as replacing
"已足够旧且不再附加到头像、帖子、文章草稿或文章的已存储媒体。" with a clearer wording like
"移除已过期且不再附着于头像、帖子、文章草稿或文章的已存储媒体。" — edit the msgstr for that msgid in
messages.po to use "已过期" (or another natural alternative) and keep the rest of
the sentence structure intact.

In `@web-next/src/routes/`(root)/[handle]/settings/index.tsx:
- Around line 283-287: The catch block that currently calls console.error(error)
after createMediumFromDataUrl(pendingAvatarUrl) should use the project's
structured logger LogTape and include the current save context; replace the raw
console.error with a LogTape error call that logs the caught error plus
structured fields such as the save object/identifier and pendingAvatarUrl so the
failure in createMediumFromDataUrl and the avatar upload (avatarMediumId) path
is recorded with proper context.

In `@web/routes/_app.tsx`:
- Around line 73-78: The inline import for Medium should be hoisted to the
existing top-level type imports: add Medium to the existing type import list
that already includes Account and AccountEmail, then update the local variable
type annotation for account (the union type using Account & { emails:
AccountEmail[]; avatarMedium?: import("@hackerspub/models/schema").Medium |
null; }) to reference Medium directly (avatarMedium?: Medium | null). Modify the
top-level type import and the account declaration to use the imported Medium
symbol.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 790d12b6-e76d-4ae7-aa62-0c6a9303241e

📥 Commits

Reviewing files that changed from the base of the PR and between 38a6e99 and 348edb5.

⛔ Files ignored due to path filters (2)
  • web-next/src/routes/(root)/admin/__generated__/mediaDeleteOrphanMediaMutation.graphql.ts is excluded by !**/__generated__/**
  • web-next/src/routes/(root)/admin/__generated__/mediaPageQuery.graphql.ts is excluded by !**/__generated__/**
📒 Files selected for processing (68)
  • drizzle/0098_unified_medium.sql
  • drizzle/0099_drop_note_source_medium_unique.sql
  • drizzle/meta/0098_snapshot.json
  • drizzle/meta/0099_snapshot.json
  • drizzle/meta/_journal.json
  • federation/actor.ts
  • federation/objects.ts
  • graphql/account.test.ts
  • graphql/account.ts
  • graphql/admin.test.ts
  • graphql/admin.ts
  • graphql/builder.ts
  • graphql/deno.json
  • graphql/main.ts
  • graphql/medium-upload.test.ts
  • graphql/medium-upload.ts
  • graphql/post.more.test.ts
  • graphql/post.ts
  • graphql/schema.graphql
  • graphql/server.ts
  • graphql/signup.ts
  • models/account.more.test.ts
  • models/account.ts
  • models/actor.ts
  • models/admin.test.ts
  • models/admin.ts
  • models/article.lifecycle.test.ts
  • models/article.ts
  • models/markup.ts
  • models/medium.test.ts
  • models/medium.ts
  • models/note.lifecycle.test.ts
  • models/note.test.ts
  • models/note.ts
  • models/post.sync.test.ts
  • models/post.ts
  • models/relations.ts
  • models/schema.ts
  • test/postgres.ts
  • web-next/relay.config.json
  • web-next/src/components/AppSidebar.tsx
  • web-next/src/components/article-composer/ArticleComposer.tsx
  • web-next/src/components/article-composer/ArticleComposerContext.tsx
  • web-next/src/lib/uploadImage.ts
  • web-next/src/locales/en-US/messages.po
  • web-next/src/locales/ja-JP/messages.po
  • web-next/src/locales/ko-KR/messages.po
  • web-next/src/locales/zh-CN/messages.po
  • web-next/src/locales/zh-TW/messages.po
  • web-next/src/routes/(root)/[handle]/settings/index.tsx
  • web-next/src/routes/(root)/admin/invitations.tsx
  • web-next/src/routes/(root)/admin/media.tsx
  • web/codegen.ts
  • web/components/AdminNav.tsx
  • web/main.ts
  • web/routes/@[username]/[idOrYear]/[slug]/index.tsx
  • web/routes/@[username]/drafts/[draftId]/publish.ts
  • web/routes/@[username]/feed.xml.ts
  • web/routes/@[username]/invite/[id]/index.tsx
  • web/routes/@[username]/og.ts
  • web/routes/@[username]/settings/index.tsx
  • web/routes/_app.tsx
  • web/routes/admin/index.tsx
  • web/routes/admin/media.tsx
  • web/routes/api/media.ts
  • web/routes/sign/in/[token].tsx
  • web/routes/sign/up/[token].tsx
  • web/utils.ts

Comment thread drizzle/0098_unified_medium.sql
Comment thread graphql/medium-upload.ts
Comment thread graphql/post.ts
Comment thread graphql/schema.graphql
Comment thread models/admin.ts Outdated
Comment thread models/medium.ts Outdated
Comment thread models/note.ts
Comment thread web-next/src/locales/zh-TW/messages.po Outdated
Comment thread web-next/src/routes/(root)/admin/media.tsx
Comment thread web/routes/@[username]/settings/index.tsx Outdated
dahlia added 4 commits May 6, 2026 21:13
Bound proxy and remote image reads while streaming, return validation
failures for corrupt image bytes, and stop reporting successful uploads
as invalid when temporary cleanup fails.

hackers-pub#286 (comment)
hackers-pub#286 (comment)
hackers-pub#286 (comment)
hackers-pub#286 (comment)

Assisted-by: Codex:gpt-5.5
Make migrated article media selection deterministic, keep orphan media rows
retryable when disk deletion fails, and avoid silent partial success when
note or article media references do not match exactly.

hackers-pub#286 (comment)
hackers-pub#286 (comment)
hackers-pub#286 (comment)
hackers-pub#286 (comment)

Assisted-by: Codex:gpt-5.5
Expose avatarMediumId on Account, keep signed-in non-moderators out of
the sign-in loop, and add glossary terms for the new orphan media
cleanup strings.

hackers-pub#286 (comment)
hackers-pub#286 (comment)
hackers-pub#286 (comment)

Assisted-by: Codex:gpt-5.5
Require the route account to match the active session before applying
legacy profile settings updates.

hackers-pub#286 (comment)

Assisted-by: Codex:gpt-5.5
@dahlia

dahlia commented May 6, 2026

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a unified media management system, replacing the previous note_medium and article_medium tables with a centralized medium table. It includes database migrations to migrate existing data, updates to the GraphQL schema to support the new media structure, and adds functionality for secure media uploads via a proxy. Additionally, it implements a cleanup mechanism for orphan media and updates the web-next stack to handle these changes, including updated internationalization files.

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: a687bc3989

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread graphql/post.ts Outdated
Comment thread models/post.ts Outdated
dahlia added 4 commits May 6, 2026 21:47
Add b-tree indexes for the new medium foreign keys so reverse lookups
and orphan media cleanup do not have to scan relation tables. Keep the
Drizzle schema and snapshots in sync with the migration.

Assisted-by: Codex:gpt-5.5
Deprecate legacy avatar and invitation timestamp aliases while keeping
old fields available for clients. Rename the orphan-media deletion
failure count to a storage-neutral GraphQL field and update Relay,
tests, and extracted translations.

Assisted-by: Codex:gpt-5.5
Resolve article medium placeholders in ActivityPub source markdown and
copy the stored medium type when syncing note media. Tighten related
account and app types so avatar medium relations are visible to callers.

Assisted-by: Codex:gpt-5.5
Cover upload proxy rejection when the declared content length is longer
than the body. Make the test disk delete keys and reuse the full test
disk shape for orphan-media deletion tests.

Assisted-by: Codex:gpt-5.5

@chatgpt-codex-connector chatgpt-codex-connector Bot 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.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 939fb09853

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread models/admin.ts
dahlia added 2 commits May 6, 2026 23:34
Keep media referenced directly from article markdown out of orphan cleanup.
Process cleanup candidates in bounded batches, delete database rows before
storage objects, and cap storage-delete concurrency to avoid unbounded I/O.

hackers-pub#286 (comment)
hackers-pub#286 (comment)
hackers-pub#286 (comment)
hackers-pub#286 (comment)

Assisted-by: Codex:gpt-5.5
Return early when a profile settings save is already in progress so duplicate
submits cannot start overlapping avatar uploads or account mutations.

hackers-pub#286 (comment)

Assisted-by: Codex:gpt-5.5
@dahlia

dahlia commented May 6, 2026

Copy link
Copy Markdown
Member Author

/gemini review

@dahlia

dahlia commented May 6, 2026

Copy link
Copy Markdown
Member Author

@codex review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a comprehensive 'Medium' system to centralize media management, replacing previous scattered implementations (like note_medium and article_medium). It includes database schema updates, a new GraphQL API for media uploads and management, and a migration of existing media data. The reviewer correctly identified a potential issue with the strpos check in the orphan media cleanup logic, which could cause false positives; this feedback is actionable and important for data integrity.

Comment thread models/admin.ts
Add an updateArticle media input so edited article markdown can introduce
new hp-medium references.  The model now validates referenced keys, updates
article_source_medium before post rendering, and rolls back the edit when a
referenced medium cannot be resolved.

Assisted-by: Codex:gpt-5.5
@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

hackerspub/graphql/post.ts

Lines 926 to 928 in 19d1bf7

const draft = await updateArticleDraft(ctx.db, {
id: id?.id ?? args.input.uuid ?? generateUuidV7(),
accountId: session.accountId,

P1 Badge Reject conflicting draft UUIDs before returning payload

When uuid is supplied for a new draft, this resolver assumes updateArticleDraft() always returns a row, but that is false if the UUID already exists under another account (the ON CONFLICT ... DO UPDATE in models/article.ts is gated by account ID). In that case draft becomes undefined, and this mutation returns a payload whose non-null draft field cannot be resolved, producing an internal GraphQL error instead of a user-facing InvalidInputError for uuid.


hackerspub/models/admin.ts

Lines 349 to 355 in 19d1bf7

return candidateIds.length < 1 ? [] : await tx
.delete(mediumTable)
.where(and(
inArray(mediumTable.id, candidateIds),
orphanMediaWhere(cutoffDate),
))
.returning({ key: mediumTable.key });

P2 Badge Delete storage objects before dropping medium rows

This function deletes medium rows inside the transaction and only then attempts disk.delete(key). If storage deletion fails transiently, the database no longer retains those keys, so future orphan-cleanup runs cannot retry them and the blobs become permanent storage leaks. The returned failedDiskDeletes count reports the failure but does not preserve retryability.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

dahlia added 2 commits May 7, 2026 00:09
Match article markdown references as full hp-medium tokens or local media
URLs instead of searching for a raw key substring. This keeps direct article
references protected without treating prefix-like keys as live.

hackers-pub#286 (comment)

Assisted-by: Codex:gpt-5.5
Return InvalidInputError when saveArticleDraft cannot upsert a supplied
UUID because it already belongs to another account. This avoids surfacing a
non-null payload with a missing draft as an internal GraphQL error.

hackers-pub#286 (comment)

Assisted-by: Codex:gpt-5.5
@dahlia

dahlia commented May 6, 2026

Copy link
Copy Markdown
Member Author

Addressed the saveArticleDraft UUID collision finding in b2c91a4. The resolver now returns InvalidInputError("uuid") instead of a non-null payload with a missing draft.

I am declining the storage-delete ordering suggestion from #286 (comment). The current DB-first order is intentional because it avoids leaving database references pointing at missing objects if a concurrent reference or transaction failure prevents the DB delete. Storage deletion failures are reported, and adding retryable tombstones would be a separate storage cleanup design.

@dahlia

dahlia commented May 6, 2026

Copy link
Copy Markdown
Member Author

/gemini review

@dahlia

dahlia commented May 6, 2026

Copy link
Copy Markdown
Member Author

@codex review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements a unified "medium" system to centralize media management for avatars, notes, and articles, featuring content-based deduplication and a new upload proxy. The changes span database migrations, GraphQL schema updates, and model-layer logic for both the legacy and new web stacks. Review feedback identifies a memory exhaustion risk in the upload proxy due to full-body buffering, performance concerns regarding redundant regex scans in the orphan media cleanup logic, and an opportunity to optimize the content hashing utility by avoiding unnecessary buffer copies.

I am having trouble creating individual review comments. Click here to see my feedback.

graphql/medium-upload.ts (139-142)

medium

The handleMediumUploadProxy function buffers the entire request body into memory. With MAX_STREAMING_MEDIUM_IMAGE_SIZE set to 50 MiB, concurrent uploads could quickly exhaust server memory. While the current storage abstraction might limit streaming options, consider implementing a more memory-efficient approach if possible, or lowering the limit if 50 MiB is not strictly necessary for all users.

models/admin.ts (270-306)

medium

The regex-based checks on article and draft content are redundant and likely to cause significant performance issues. Since updateArticleSourceMedia ensures that all media referenced in the content have corresponding entries in the article_draft_medium or article_source_medium relation tables, the NOT EXISTS checks on those tables are sufficient to identify orphans. Performing a full-table regex scan on large text columns for every candidate medium is extremely expensive and should be avoided. Removing these checks also allows removing the unused regex pattern definitions.

  return sql`\n    ${mediumTable.created} < ${cutoffDateSql} AND\n    NOT EXISTS (\n      SELECT 1 FROM ${accountTable}\n      WHERE ${accountTable.avatarMediumId} = ${mediumTable.id}\n    ) AND\n    NOT EXISTS (\n      SELECT 1 FROM ${noteSourceMediumTable}\n      WHERE ${noteSourceMediumTable.mediumId} = ${mediumTable.id}\n    ) AND\n    NOT EXISTS (\n      SELECT 1 FROM ${articleDraftMediumTable}\n      WHERE ${articleDraftMediumTable.mediumId} = ${mediumTable.id}\n    ) AND\n    NOT EXISTS (\n      SELECT 1 FROM ${articleSourceMediumTable}\n      WHERE ${articleSourceMediumTable.mediumId} = ${mediumTable.id}\n    )\n  `;

models/medium.ts (118-120)

medium

The sha256Hex function creates an unnecessary copy of the input data. crypto.subtle.digest can accept the Uint8Array directly. Passing the Uint8Array instead of copying it into a new buffer improves efficiency, especially for larger media files.

  const hashBuffer = await crypto.subtle.digest("SHA-256", data);

@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

sql`'/media/' || ${mediumKeyPattern} || ${mediumReferenceBoundary}`;

P2 Badge Match direct media URLs without duplicating media/ prefix

orphanMediaWhere builds directMediumReferencePattern as '/media/' || mediumTable.key, but stored keys already include the media/ prefix (for example from createMediumFromBytes), so the regex looks for /media/media/... and misses real /media/... references in article content. In that case deleteOrphanMedia can classify still-linked media as orphan and delete it, breaking direct image links in drafts/articles that reference stored media URLs instead of relation keys.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Preserve direct article references that use the stored medium key path as
well as local FS URLs and hp-medium placeholders. This covers CDN-style URLs
whose path is based directly on the medium key.

hackers-pub#286 (comment)

Assisted-by: Codex:gpt-5.5
@dahlia

dahlia commented May 6, 2026

Copy link
Copy Markdown
Member Author

/gemini review

@dahlia

dahlia commented May 6, 2026

Copy link
Copy Markdown
Member Author

Addressed in 07389e3. Orphan cleanup now preserves direct references that use the stored medium key path as well as local /media/ URLs and hp-medium placeholders.

@dahlia

dahlia commented May 6, 2026

Copy link
Copy Markdown
Member Author

@codex review

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request introduces a unified 'medium' system to replace fragmented media handling across notes, articles, and user avatars. It adds a new 'medium' table, updates the database schema, and implements a secure proxy for media uploads. The changes include GraphQL schema updates to support the new medium system, migration scripts, and updated test suites. The review comments provided actionable feedback regarding memory management for large file uploads, resource-intensive image processing, concurrency tuning for orphan media deletion, and validation of media key patterns, all of which are valid and constructive.

I am having trouble creating individual review comments. Click here to see my feedback.

graphql/medium-upload.ts (66-91)

medium

The readRequestBody function reads the entire request body into memory. For large uploads (up to 50MB), this could lead to significant memory pressure and potential OOM issues under concurrent load. Consider using a streaming approach if the underlying flydrive disk implementation supports it, or at least ensure that the maxSize is strictly enforced to prevent memory exhaustion.

models/medium.ts (189-192)

medium

Using sharp(input, { animated: true }) is correct for handling GIFs, but be aware that processing large animated images can be CPU and memory intensive. Ensure that the server has sufficient resources to handle concurrent processing of such images.

models/admin.ts (313-331)

medium

The mapWithConcurrency function is a good way to limit concurrency. However, ensure that the ORPHAN_MEDIA_STORAGE_DELETE_CONCURRENCY constant is tuned appropriately for the environment to avoid overwhelming the storage provider or the database.

models/article.ts (54-55)

medium

The regex articleMediumReferencePattern and articleMediumKeyPattern are used for validating media keys. Ensure these patterns are robust enough to handle all valid key formats, as invalid keys could lead to broken article content.

@chatgpt-codex-connector

Copy link
Copy Markdown

💡 Codex Review

const media = await db.query.articleDraftMediumTable.findMany({
where: { articleDraftId: draftId },
with: { medium: true },
});

P2 Badge Add deterministic ordering for article medium URL maps

renderMarkup() now hashes JSON.stringify(options.mediumUrls) into the KV cache key (models/markup.ts), but this helper builds mediumUrls from an unordered findMany() result. Because SQL row order is not guaranteed without orderBy, the same article can produce different object key orders across requests, causing cache-key churn and avoidable re-renders even when content and media are unchanged. Sorting relations by key (or canonicalizing keys before stringify) would keep cache hits stable.

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

dahlia added 2 commits May 7, 2026 01:11
Replace unresolved hp-medium references with generated SVG placeholders
when rendering article and draft markup. Use article language where it is
available, and keep user-authored data URLs blocked by resolving medium
references after Markdown rendering.

Cover image, link, and srcset references with regression tests.

Assisted-by: Codex:gpt-5.5
Canonicalize medium URL mappings before hashing markup cache keys so
unordered database results do not cause cache churn for identical article
content.

hackers-pub#286 (comment)

Assisted-by: Codex:gpt-5.5
@dahlia

dahlia commented May 6, 2026

Copy link
Copy Markdown
Member Author

Addressed #286 (comment) in 52d54da. The markup cache key now canonicalizes medium URL entries before hashing, so unordered DB results do not churn cache keys.

@dahlia

dahlia commented May 6, 2026

Copy link
Copy Markdown
Member Author

@codex review

@dahlia

dahlia commented May 6, 2026

Copy link
Copy Markdown
Member Author

/gemini review

@gemini-code-assist

Copy link
Copy Markdown
Contributor

Warning

Gemini is experiencing higher than usual traffic and was unable to create the review. Please try again in a few hours by commenting /gemini review.

@chatgpt-codex-connector

Copy link
Copy Markdown

Codex Review: Didn't find any major issues. Can't wait for the next one!

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

@dahlia dahlia merged commit bc66f61 into hackers-pub:main May 6, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant