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
+57
View File
@@ -0,0 +1,57 @@
-- 008_connected_pages.sql — Notion-like upgrades phase 3.
--
-- Nested pages, resolved internal page links (backlinks), and live collection
-- views embedded in a page. Links and embeds are derived from Markdown on
-- every content save: the page body stays the single source of truth.
--
-- Access rules:
-- * Pages remain owner-scoped. A link to a page the author does not own is
-- ignored, so backlinks can never reveal a title across accounts.
-- * An embed is stored only when the author owns the collection. The live
-- view still re-checks bookmark visibility through the collection item
-- endpoints (N-08); embedding never publishes anything.
--
-- Deleting:
-- * Deleting a parent page promotes its children (SET NULL), never deletes
-- them silently.
-- * Deleting a page cascades its outgoing/incoming links and embeds.
-- * Deleting a collection cascades its embeds; deleting a view clears the
-- embed's viewId so it falls back to the collection's default view.
ALTER TABLE "Page" ADD COLUMN IF NOT EXISTS "parentId" text;
-- The named constraint matches schema.sql so the fresh and upgraded paths
-- converge. The guard makes a re-run against an already-current schema a
-- no-op (without it, schema.sql's inline FK would collide).
DO $$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'Page_parentId_fkey' AND conrelid = '"Page"'::regclass
) THEN
ALTER TABLE "Page" ADD CONSTRAINT "Page_parentId_fkey"
FOREIGN KEY ("parentId") REFERENCES "Page"(id) ON DELETE SET NULL;
END IF;
END $$;
CREATE INDEX IF NOT EXISTS "Page_parentId_idx" ON "Page"("parentId");
CREATE TABLE IF NOT EXISTS "PageLink" (
"sourcePageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE,
"targetPageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE,
"createdAt" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("sourcePageId", "targetPageId"),
CONSTRAINT "PageLink_no_self" CHECK ("sourcePageId" <> "targetPageId")
);
CREATE INDEX IF NOT EXISTS "PageLink_targetPageId_idx" ON "PageLink"("targetPageId");
CREATE TABLE IF NOT EXISTS "PageEmbed" (
"pageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE,
"collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE,
"viewId" text REFERENCES "CollectionView"(id) ON DELETE SET NULL,
position integer NOT NULL DEFAULT 0,
"createdAt" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("pageId", "collectionId")
);
CREATE INDEX IF NOT EXISTS "PageEmbed_collectionId_idx" ON "PageEmbed"("collectionId");
CREATE INDEX IF NOT EXISTS "PageEmbed_viewId_idx" ON "PageEmbed"("viewId");
+35
View File
@@ -196,6 +196,10 @@ CREATE TABLE "Page" (
icon text,
pinned boolean NOT NULL DEFAULT false,
position int NOT NULL DEFAULT 0,
-- Connected pages (phase 3): a page may live under another page. Deleting
-- the parent promotes its children to the top level instead of deleting
-- them; cycle rejection is enforced by the API.
"parentId" text REFERENCES "Page"(id) ON DELETE SET NULL,
-- Optimistic concurrency revision (review DATA-01): content saves bump it;
-- a stale base version in PATCH is rejected with 409.
version integer NOT NULL DEFAULT 1,
@@ -204,6 +208,7 @@ CREATE TABLE "Page" (
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX "Page_ownerId_position_idx" ON "Page"("ownerId", position);
CREATE INDEX "Page_parentId_idx" ON "Page"("parentId");
-- Index-backed substring search over the two searchable fields.
CREATE INDEX "Page_title_trgm_idx" ON "Page" USING gin (title gin_trgm_ops);
CREATE INDEX "Page_content_trgm_idx" ON "Page" USING gin (content gin_trgm_ops);
@@ -218,6 +223,19 @@ CREATE TABLE "PageBookmark" (
);
CREATE INDEX "PageBookmark_bookmarkId_idx" ON "PageBookmark"("bookmarkId");
-- 2.13b PageLink: resolved internal page links, rebuilt from page content on
-- every content save. Targets are only stored when the author owns the target
-- page, so a backlink can never reveal an inaccessible title. Self-links are
-- ignored by the writer and refused here as defense in depth.
CREATE TABLE "PageLink" (
"sourcePageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE,
"targetPageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE,
"createdAt" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("sourcePageId", "targetPageId"),
CONSTRAINT "PageLink_no_self" CHECK ("sourcePageId" <> "targetPageId")
);
CREATE INDEX "PageLink_targetPageId_idx" ON "PageLink"("targetPageId");
-- 2.14 SignalSource
CREATE TABLE "SignalSource" (
id text PRIMARY KEY,
@@ -348,4 +366,21 @@ CREATE TABLE "CollectionView" (
"updatedAt" timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX "CollectionView_collectionId_idx" ON "CollectionView"("collectionId");
-- 2.18 PageEmbed (Notion-like upgrades phase 3): a live collection view
-- embedded in a page. The reference is resolved from the page's Markdown
-- (`{{collection:<id>|view=<id>}}`) on every content save. Only collections
-- owned by the page's author are stored; embedding displays data and never
-- publishes it or widens bookmark access. Deleting the collection or the
-- chosen view removes/falls back rather than leaving a dangling embed.
CREATE TABLE "PageEmbed" (
"pageId" text NOT NULL REFERENCES "Page"(id) ON DELETE CASCADE,
"collectionId" text NOT NULL REFERENCES "Collection"(id) ON DELETE CASCADE,
"viewId" text REFERENCES "CollectionView"(id) ON DELETE SET NULL,
position integer NOT NULL DEFAULT 0,
"createdAt" timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY ("pageId", "collectionId")
);
CREATE INDEX "PageEmbed_collectionId_idx" ON "PageEmbed"("collectionId");
CREATE INDEX "PageEmbed_viewId_idx" ON "PageEmbed"("viewId");
COMMIT;