5.2.3 Link-on-write — parse referenced blob URLs in description/explanation + 5.1 comment bodies; link/unlink attachment rows (`source: editor`)
Estimate: 26m · Depends on: 5.2.1, 5.1.2
The embeds-ARE-attachments rule, server-side. Editor uploads write unlinked rows (create modal: the issue does not exist yet), so linkage happens at BODY-WRITE time — the same parse-on-write pattern as 5.1's mentions.
Shared helper (lib/blob/referencedUrls.ts): extract our blob URLs from a Markdown body (image embeds + file links; match on the blob-host + the attachments/<workspaceId>/ pathname prefix so only OUR uploads qualify — a pasted foreign URL never links anything; workspace-scoped lookup via findManyByBlobUrls).
Wire into the write paths (inside each owning service's existing transaction):
workItemsService.createWorkItem/updateWorkItem— on a description/explanation write, diff referenced URLs against the issue's currently-linked editor-sourced rows: newly-referenced rows link (source: 'editor', + the revision attachment-added entry — uniform History, the fill of Jira's documented editor-add changelog gap); no-longer-referenced editor-sourced rows unlink (revision attachment-removed entry; the row goes GC-eligible — the Jira analogue: deleting the comment removes the attachment from the work item). Panel-sourced rows are NEVER touched by body diffs.commentsService.addComment/editComment/deleteComment(5.1.2) — comment bodies get the same parse: add/edit links newly-referenced rows to the COMMENT's work item; a comment delete (incl. thread cascade) unlinks the rows its bodies referenced (unless another body on the same issue still references the URL — the diff is per-issue across the bodies that reference it; keep the check bounded: match against the issue's linked editor rows only).
Idempotent + bounded: re-saving an unchanged body is a no-op; the parse is per-write over ONE body + one bounded findManyByBlobUrls lookup — never a scan of the attachment table.
Acceptance criteria
- Creating an issue whose description embeds an upload links that row (
source: 'editor', workItemId set) in the create transaction; cancelling the modal leaves the row unlinked (GC-eligible, 5.2.7). - Editing a body links newly-referenced rows and unlinks de-referenced editor-sourced rows, each with the matching revision entry; panel-sourced rows are untouched; re-saving unchanged is a no-op.
- Comment add/edit/delete (5.1.2) round-trips the same linkage incl. the still-referenced-elsewhere guard; a foreign/random URL in a body never links or unlinks anything (workspace + pathname-prefix scoped).
- All linkage happens inside the owning service's existing transaction (one method = one tx);
pnpm test:coverage≥90% on the helper + touched paths.
Context refs
lib/blob/uploader.ts(2.3.7 — the pathname structureattachments/<workspaceId>/the matcher keys on) +lib/blob/uploadClient.ts(what the editor inserts)workItemsServicecreate/update (the description write paths);commentsService(5.1.2) — the comment write pathslib/mentions/parse.ts(5.1.2) — the sibling parse-on-write pattern to mirror- The Jira coupling contract (embeds are attachments; comment delete removes them) in the Story 5.2 description