Files
thoughtsync/alembic/versions/0023_note_link_target_id.py
bvandeusen 982d24c83b
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
links: bind a [[link]] to a note, not to a string
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.
2026-08-22 11:02:39 -04:00

68 lines
2.3 KiB
Python

"""note_links.target_id — resolve [[links]] to a note, not to a string (M13 step 1)
Revision ID: 0023
Revises: 0022
Create Date: 2026-08-22
A wiki-link stored only as normalized TEXT means a note's name IS the edge: rename
the note and every inbound link stops matching. The old answer was to rewrite the
`[[Old Name]]` text inside every note that linked to it — workable while an explicit
title existed to hold still, untenable once a note's name is just its first body
line (M13).
`target_norm` stays: it is what an UNRESOLVED link carries, since linking to a note
that doesn't exist yet is a supported way to create one.
The backfill is safe to run bluntly because note_links is DERIVED data — every row
is recomputed from the source body on the next save regardless.
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision = "0023"
down_revision = "0022"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.add_column(
"note_links",
sa.Column("target_id", postgresql.UUID(as_uuid=True), nullable=True),
)
op.create_foreign_key(
"fk_note_links_target",
"note_links",
"notes",
["target_id"],
["id"],
# A deleted target un-resolves its inbound links rather than deleting them:
# the link text is still in the source's body, and it should read as pointing
# at something that isn't there — which is also what lets it re-resolve if a
# note of that name appears again.
ondelete="SET NULL",
)
op.create_index("ix_note_links_target_id", "note_links", ["target_id"])
# Resolve what can be resolved right now, scoped to the source's owner so a link
# can never bind to another user's note.
op.execute(
"""
UPDATE note_links AS nl
SET target_id = t.id
FROM notes AS src, notes AS t
WHERE nl.source_id = src.id
AND t.owner_id = src.owner_id
AND t.deleted_at IS NULL
AND lower(btrim(t.display_title)) = nl.target_norm
AND t.id <> src.id
"""
)
def downgrade() -> None:
op.drop_index("ix_note_links_target_id", table_name="note_links")
op.drop_constraint("fk_note_links_target", "note_links", type_="foreignkey")
op.drop_column("note_links", "target_id")