Add connected pages: hierarchy, backlinks, and live embeds
release gate / check (push) Failing after 7m7s
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:
@@ -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");
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user