links: bind a [[link]] to a note, not to a string
CI & Build / Build now, or wait for Android? (push) Successful in 3s
CI & Build / Python lint (push) Successful in 4s
CI & Build / TypeScript typecheck (push) Successful in 7s
CI & Build / Python tests (push) Successful in 11s
CI & Build / Build & push image (push) Successful in 34s
Desktop (Tauri) / Windows installer (cross-compiled) (push) Successful in 2m21s
Desktop (Tauri) / Tauri desktop (Linux) (push) Successful in 4m18s
Desktop (Tauri) / Update manifest (push) Successful in 5s

A wiki-link was stored only as normalized TEXT, so a note's NAME was the edge.
Renaming it broke every inbound link — and the fix that shipped for that
(task 1848, option b) was `_rename_inbound_links`: rewrite the `[[Old Name]]`
text inside the body of every note that linked to the renamed one.

That works while an explicit title exists to hold still. It stops being
defensible the moment a note's name is just its first body line, which is where
M13 is going: fixing a typo in your opening sentence would silently edit other
notes' words, with nothing to opt out to. So this lands first, before the title
comes out, and that window never ships.

`note_links` gains `target_id`, bound when the link is written. `target_norm`
stays and is what an UNRESOLVED link carries — linking to a note that doesn't
exist yet is a supported way to create one, so a link has to be able to name a
target that isn't there. Resolution reads the id, falling back to the name only
where nothing was bound, which is what lets a forward link connect the moment
its target appears. `_claim_unresolved_links` then binds it, so the fallback is
a transitional state rather than a permanent one.

`_rename_inbound_links` and `rewrite_link_title` are gone. What replaced them
touches link rows only: a note's text is never modified by something happening
to a different note.

The client can no longer resolve links for itself, and that is the point. It
used to look `[[text]]` up in a client-side name index, which only held together
BECAUSE renaming rewrote the text everywhere. Now the written text can name
something the target is no longer called, and only the server holds the binding
— so each note serializes its resolved links (`norm`, `id`, and the target's
name as it stands NOW). A renamed note reads correctly everywhere it is linked
from, without a single body having been edited. Unresolved links are simply
absent and fall through to the create-on-click affordance that already existed;
so does the offline desktop store, which derives links at query time and has no
binding to send.

The name-fallback join is owner-scoped everywhere it appears. Bound ids were
resolved owner-scoped when written, but matching on display_title alone would
have let two users who each have a note called "Groceries" see the other's id
and name through an unresolved link (rule 47).

The new behaviour is all SQL and this suite runs without a database, so the
dead helpers' tests are removed rather than replaced. This repo has no
integration lane to hold that ground — noted, not papered over.
This commit is contained in:
2026-08-22 11:02:39 -04:00
parent bacedea8a3
commit 982d24c83b
13 changed files with 352 additions and 96 deletions
+67 -3
View File
@@ -1,14 +1,16 @@
"""Note serialization — turn a Note (+ its labels/items/attachments/previews) into
the JSON dict the API returns. The bulk loaders (`*_for_notes`) fetch each child
"""Note serialization — turn a Note (+ its labels/items/attachments/previews/links)
into the JSON dict the API returns. The bulk loaders (`*_for_notes`) fetch each child
collection for a batch of notes in one query, so list endpoints avoid N+1s."""
from __future__ import annotations
from sqlalchemy import select
from sqlalchemy import and_, func, or_, select
from sqlalchemy.orm import aliased
from ..models.label import Label, NoteLabel
from ..models.note import Note
from ..models.note_attachment import NoteAttachment
from ..models.note_item import NoteItem
from ..models.note_link import NoteLink
from ..models.note_link_preview import NoteLinkPreview
@@ -104,6 +106,64 @@ async def _previews_for_notes(db, note_ids: list) -> dict:
return result
async def _links_for_notes(db, note_ids: list) -> dict:
"""Map note_id -> [{norm, id, title}] for each note's RESOLVED outgoing links.
The client cannot work this out for itself any more, and that is deliberate. It
used to resolve `[[text]]` by looking the text up in a client-side name index,
which only worked because a rename rewrote the text in every linking note. Now
that a link is bound to an id and the text is left alone, the stored text can name
something the target is no longer called — so the server, which holds the binding,
is the only place that knows where a link goes.
`title` is the target's name RIGHT NOW, so a renamed note reads correctly
everywhere it is linked from without a single body having been edited.
Unresolved links are simply absent: the client renders those as the
create-on-click affordance it already has.
"""
if not note_ids:
return {}
source = aliased(Note)
target = aliased(Note)
rows = (
await db.execute(
select(NoteLink.source_id, NoteLink.target_norm, target.id, target.display_title)
.select_from(NoteLink)
.join(source, source.id == NoteLink.source_id)
.join(
target,
or_(
target.id == NoteLink.target_id,
and_(
NoteLink.target_id.is_(None),
func.lower(func.trim(target.display_title)) == NoteLink.target_norm,
),
),
)
.where(
NoteLink.source_id.in_(note_ids),
target.deleted_at.is_(None),
# Owner-scoped, and NOT optional. A bound target_id was resolved
# owner-scoped when it was written, but the name fallback matches on
# display_title alone — without this, two users who both have a note
# called "Groceries" would leak each other's note id and name through
# an unresolved link. (Rule 47.)
target.owner_id == source.owner_id,
)
)
).all()
result: dict = {}
for source_id, norm, target_id, title in rows:
bucket = result.setdefault(source_id, [])
# The name-fallback join can produce more than one candidate for the same
# text; first one wins, deterministically enough for a display hint.
if any(link["norm"] == norm for link in bucket):
continue
bucket.append({"norm": norm, "id": str(target_id), "title": title})
return result
async def _serialize_note(db, note: Note) -> dict:
data = note.serialize()
labels = await _labels_for_notes(db, [note.id])
@@ -114,6 +174,8 @@ async def _serialize_note(db, note: Note) -> dict:
data["attachments"] = attachments.get(note.id, [])
previews = await _previews_for_notes(db, [note.id])
data["previews"] = previews.get(note.id, [])
links = await _links_for_notes(db, [note.id])
data["links"] = links.get(note.id, [])
return data
@@ -123,6 +185,7 @@ async def _serialize_notes(db, notes: list) -> list:
items_map = await _items_for_notes(db, ids)
attach_map = await _attachments_for_notes(db, ids)
preview_map = await _previews_for_notes(db, ids)
link_map = await _links_for_notes(db, ids)
out = []
for n in notes:
data = n.serialize()
@@ -130,5 +193,6 @@ async def _serialize_notes(db, notes: list) -> list:
data["items"] = items_map.get(n.id, [])
data["attachments"] = attach_map.get(n.id, [])
data["previews"] = preview_map.get(n.id, [])
data["links"] = link_map.get(n.id, [])
out.append(data)
return out