Add connected pages: hierarchy, backlinks, and live embeds
release gate / check (push) Failing after 7m7s

Implement Notion-like upgrade phase 3: owner-scoped nested pages with cycle rejection and child promotion on delete; Markdown-derived internal links and backlinks; live collection embeds resolved in the content-save transaction. Add migration 008, API/docs, integration coverage, and page/sidebar UI.
This commit is contained in:
Andrew S Erwin
2026-10-19 22:37:21 -05:00
parent b9721ff13f
commit 9db69efe4b
12 changed files with 938 additions and 28 deletions
+69 -5
View File
@@ -18,7 +18,23 @@ import (
func pageView(ctx context.Context, q db.Queryer, id, uid string) (object, error) {
// Linked bookmarks use the viewer projection (review PRIV-02): a page can
// link bookmarks shared with its owner, and those arrive narrowed.
return db.One(ctx, q, `SELECT to_jsonb(p)||jsonb_build_object('bookmarks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('bookmark',`+db.BookmarkViewerView+`) ORDER BY pb.position) FROM "PageBookmark" pb JOIN "Bookmark" b ON b.id=pb."bookmarkId" WHERE pb."pageId"=p.id AND `+db.BookmarkVisible+`),'[]'::jsonb)) FROM "Page" p WHERE p.id=$1 AND p."ownerId"=$2`, id, uid)
//
// Connected pages (phase 3): backlinks and embeds are owner-scoped in the
// query itself, not trusted from the caller. `links` is the resolved
// outgoing edge set (useful for navigation); the Markdown remains the
// source of truth and is rebuilt by syncPageGraph on every content save.
return db.One(ctx, q, `SELECT to_jsonb(p)||jsonb_build_object(
'bookmarks',COALESCE((SELECT jsonb_agg(to_jsonb(pb)||jsonb_build_object('bookmark',`+db.BookmarkViewerView+`) ORDER BY pb.position) FROM "PageBookmark" pb JOIN "Bookmark" b ON b.id=pb."bookmarkId" WHERE pb."pageId"=p.id AND `+db.BookmarkVisible+`),'[]'::jsonb),
'backlinks',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',s.id,'title',s.title,'updatedAt',s."updatedAt") ORDER BY s."updatedAt" DESC,s.id) FROM "PageLink" pl JOIN "Page" s ON s.id=pl."sourcePageId" WHERE pl."targetPageId"=p.id AND s."ownerId"=p."ownerId"),'[]'::jsonb),
'links',COALESCE((SELECT jsonb_agg(jsonb_build_object('id',t.id,'title',t.title) ORDER BY t.title,t.id) FROM "PageLink" pl JOIN "Page" t ON t.id=pl."targetPageId" WHERE pl."sourcePageId"=p.id AND t."ownerId"=p."ownerId"),'[]'::jsonb),
'embeds',COALESCE((SELECT jsonb_agg(jsonb_build_object(
'collectionId',e."collectionId",'viewId',e."viewId",'position',e.position,
'collection',to_jsonb(c)||jsonb_build_object(
'properties',COALESCE((SELECT jsonb_agg(to_jsonb(pr) ORDER BY pr.position,pr.id) FROM "CollectionProperty" pr WHERE pr."collectionId"=c.id),'[]'::jsonb),
'views',COALESCE((SELECT jsonb_agg(to_jsonb(v) ORDER BY v.position,v.id) FROM "CollectionView" v WHERE v."collectionId"=c.id),'[]'::jsonb)),
'view',(SELECT to_jsonb(v) FROM "CollectionView" v WHERE v.id=e."viewId"))
ORDER BY e.position,e."collectionId") FROM "PageEmbed" e JOIN "Collection" c ON c.id=e."collectionId" WHERE e."pageId"=p.id AND c."ownerId"=p."ownerId"),'[]'::jsonb)
) FROM "Page" p WHERE p.id=$1 AND p."ownerId"=$2`, id, uid)
}
func page(r *http.Request, id string) (object, error) {
return pageView(r.Context(), database(r), id, user(r))
@@ -31,12 +47,15 @@ func HandlePagesList(w http.ResponseWriter, r *http.Request) {
// literal characters, not patterns. The pg_trgm indexes on title/content
// let Postgres use the same ILIKE predicate without scanning every page.
search := "%" + likeEscaper.Replace(r.URL.Query().Get("search")) + "%"
v, e := db.Many(r.Context(), database(r), `SELECT (to_jsonb(p)-'content')||jsonb_build_object('_count',jsonb_build_object('bookmarks',(SELECT count(*) FROM "PageBookmark" pb WHERE pb."pageId"=p.id))) FROM "Page" p WHERE "ownerId"=$1 AND (title ILIKE $2 ESCAPE '\' OR content ILIKE $2 ESCAPE '\') ORDER BY pinned DESC,position,"createdAt"`, user(r), search)
v, e := db.Many(r.Context(), database(r), `SELECT (to_jsonb(p)-'content')||jsonb_build_object('_count',jsonb_build_object(
'bookmarks',(SELECT count(*) FROM "PageBookmark" pb WHERE pb."pageId"=p.id),
'children',(SELECT count(*) FROM "Page" ch WHERE ch."parentId"=p.id AND ch."ownerId"=p."ownerId"))) FROM "Page" p WHERE "ownerId"=$1 AND (title ILIKE $2 ESCAPE '\' OR content ILIKE $2 ESCAPE '\') ORDER BY pinned DESC,position,"createdAt"`, user(r), search)
respond(w, 200, v, e)
}
func pageData(b object) (object, error) {
d := fields(b, "title content icon pinned")
if e := stringFields(d, "icon", "title content"); e != nil {
d := fields(b, "title content icon pinned parentId")
// parentId is nullable: an explicit null promotes the page to the root.
if e := stringFields(d, "icon parentId", "title content"); e != nil {
return nil, e
}
if e := boolFields(d, "pinned"); e != nil {
@@ -80,6 +99,13 @@ func HandlePagesCreate(w http.ResponseWriter, r *http.Request) {
if e := lockUser(r, tx); e != nil {
return e
}
if raw, ok := d["parentId"]; ok {
parent, e := pageParent(r.Context(), tx, d["id"].(string), user(r), raw)
if e != nil {
return e
}
d["parentId"] = parent
}
var n int
if e := tx.QueryRowContext(r.Context(), `SELECT COALESCE(max(position),-1)+1 FROM "Page" WHERE "ownerId"=$1`, user(r)).Scan(&n); e != nil {
return e
@@ -87,6 +113,17 @@ func HandlePagesCreate(w http.ResponseWriter, r *http.Request) {
d["position"] = n
var e error
v, e = db.Insert(r.Context(), tx, "Page", d)
if e != nil {
return e
}
if content, ok := d["content"].(string); ok {
if e := syncPageGraph(r.Context(), tx, d["id"].(string), user(r), content); e != nil {
return e
}
}
// Acknowledge the same snapshot the write produced (the update path's
// CR-18 discipline), so the 201 already carries links/embeds.
v, e = pageView(r.Context(), tx, d["id"].(string), user(r))
return e
})
respond(w, 201, v, e)
@@ -162,13 +199,25 @@ func HandlePagesUpdate(w http.ResponseWriter, r *http.Request) {
return conflict("Page was modified elsewhere; reload to see the latest version")
}
}
if raw, ok := d["parentId"]; ok {
parent, e := pageParent(r.Context(), tx, id, user(r), raw)
if e != nil {
return e
}
d["parentId"] = parent
}
if _, e := db.Update(r.Context(), tx, "Page", id, "ownerId", user(r), d); e != nil {
return e
}
if _, ok := d["content"]; ok {
if content, ok := d["content"].(string); ok {
if _, e := tx.ExecContext(r.Context(), `UPDATE "Page" SET version=version+1 WHERE id=$1`, id); e != nil {
return e
}
// Rebuild resolved links/embeds in the same transaction as the
// Markdown they were derived from.
if e := syncPageGraph(r.Context(), tx, id, user(r), content); e != nil {
return e
}
}
if bid := str(b, "addBookmarkId"); bid != "" {
if _, e := db.One(r.Context(), tx, `SELECT to_jsonb(b) FROM "Bookmark" b WHERE b.id=$1 AND `+db.BookmarkVisible, bid, user(r)); e != nil {
@@ -190,7 +239,22 @@ func HandlePagesUpdate(w http.ResponseWriter, r *http.Request) {
})
respond(w, 200, v, e)
}
// HandlePagesBacklinks returns the pages that link to this one. It is a
// separate read surface for navigation; the same owner-scoped list is
// embedded in pageView for the editor.
func HandlePagesBacklinks(w http.ResponseWriter, r *http.Request) {
id := r.PathValue("id")
if _, e := owned(r, database(r), "Page", id, "ownerId"); e != nil {
respond(w, 0, nil, e)
return
}
v, e := db.Many(r.Context(), database(r), `SELECT jsonb_build_object('id',s.id,'title',s.title,'updatedAt',s."updatedAt") FROM "PageLink" pl JOIN "Page" s ON s.id=pl."sourcePageId" WHERE pl."targetPageId"=$1 AND s."ownerId"=$2 ORDER BY s."updatedAt" DESC,s.id`, id, user(r))
respond(w, 200, v, e)
}
func HandlePagesDelete(w http.ResponseWriter, r *http.Request) {
// Children are promoted to the top level by the self-FK's SET NULL; a
// delete never silently takes nested pages with it.
respond(w, 200, OK{true}, remove(r, "Page", r.PathValue("id"), "ownerId"))
}
func HandlePagesMarkdown(w http.ResponseWriter, r *http.Request) {