snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
72
apps/dotcom/zero-cache/migrations/000_seed.sql
Normal file
72
apps/dotcom/zero-cache/migrations/000_seed.sql
Normal file
@@ -0,0 +1,72 @@
|
||||
CREATE TABLE "user" (
|
||||
"id" VARCHAR PRIMARY KEY,
|
||||
"name" VARCHAR NOT NULL,
|
||||
"email" VARCHAR NOT NULL,
|
||||
"avatar" VARCHAR NOT NULL,
|
||||
"color" VARCHAR NOT NULL,
|
||||
"exportFormat" VARCHAR NOT NULL,
|
||||
"exportTheme" VARCHAR NOT NULL,
|
||||
"exportBackground" BOOLEAN NOT NULL,
|
||||
"exportPadding" BOOLEAN NOT NULL,
|
||||
"createdAt" BIGINT NOT NULL,
|
||||
"updatedAt" BIGINT NOT NULL,
|
||||
"flags" VARCHAR NOT NULL,
|
||||
"locale" VARCHAR,
|
||||
"animationSpeed" BIGINT,
|
||||
"edgeScrollSpeed" BIGINT,
|
||||
"colorScheme" VARCHAR,
|
||||
"isSnapMode" BOOLEAN,
|
||||
"isWrapMode" BOOLEAN,
|
||||
"isDynamicSizeMode" BOOLEAN,
|
||||
"isPasteAtCursorMode" BOOLEAN
|
||||
);
|
||||
|
||||
CREATE TABLE "file" (
|
||||
"id" VARCHAR PRIMARY KEY,
|
||||
"name" VARCHAR NOT NULL,
|
||||
"ownerId" VARCHAR NOT NULL,
|
||||
"thumbnail" VARCHAR NOT NULL,
|
||||
"shared" BOOLEAN NOT NULL,
|
||||
"sharedLinkType" VARCHAR NOT NULL,
|
||||
"published" BOOLEAN NOT NULL,
|
||||
"lastPublished" BIGINT NOT NULL,
|
||||
"publishedSlug" VARCHAR NOT NULL,
|
||||
"createdAt" BIGINT NOT NULL,
|
||||
"updatedAt" BIGINT NOT NULL,
|
||||
"isEmpty" BOOLEAN NOT NULL,
|
||||
FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE "file_state" (
|
||||
"userId" VARCHAR NOT NULL,
|
||||
"fileId" VARCHAR NOT NULL,
|
||||
"firstVisitAt" BIGINT,
|
||||
"lastEditAt" BIGINT,
|
||||
"lastSessionState" VARCHAR,
|
||||
"lastVisitAt" BIGINT,
|
||||
PRIMARY KEY ("userId", "fileId"),
|
||||
FOREIGN KEY ("userId") REFERENCES "user" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("fileId") REFERENCES "file" ("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE "user_mutation_number" (
|
||||
"userId" VARCHAR NOT NULL PRIMARY KEY,
|
||||
"mutationNumber" BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE PUBLICATION alltables FOR ALL TABLES;
|
||||
|
||||
CREATE OR REPLACE FUNCTION delete_file_states() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF OLD.shared = TRUE AND NEW.shared = FALSE THEN
|
||||
DELETE FROM file_state WHERE "fileId" = OLD.id AND OLD."ownerId" != "userId";
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Step 2: Create the trigger
|
||||
CREATE TRIGGER file_shared_update
|
||||
AFTER UPDATE OF shared ON file
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION delete_file_states();
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE "replicator_boot_id" (
|
||||
"replicatorId" VARCHAR NOT NULL PRIMARY KEY,
|
||||
"bootId" VARCHAR NOT NULL
|
||||
);
|
||||
9
apps/dotcom/zero-cache/migrations/002_add_user_id.sql
Normal file
9
apps/dotcom/zero-cache/migrations/002_add_user_id.sql
Normal file
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE "user_boot_id" (
|
||||
"userId" VARCHAR NOT NULL PRIMARY KEY,
|
||||
"bootId" VARCHAR NOT NULL
|
||||
);
|
||||
|
||||
-- drop the replicator_boot table later
|
||||
|
||||
CREATE INDEX file_owner_index ON public.file ("ownerId");
|
||||
CREATE INDEX file_shared_index ON public.file ("shared");
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Purpose: Make the publishedSlug column unique in the file table.
|
||||
|
||||
ALTER TABLE public.file
|
||||
ADD CONSTRAINT unique_publishedSlug UNIQUE ("publishedSlug");
|
||||
@@ -0,0 +1,38 @@
|
||||
-- Purpose: Add a column to the file_state table that indicates whether the user is the owner of the file.
|
||||
|
||||
ALTER TABLE file_state
|
||||
ADD COLUMN "isFileOwner" BOOLEAN;
|
||||
|
||||
-- To make sure it is always up to date, we add two triggers:
|
||||
|
||||
-- When the file_state table is updated, we want to check if the user is the owner of the file
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_is_file_owner() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW."isFileOwner" := (NEW."userId" = (SELECT "ownerId" FROM file WHERE file.id = NEW."fileId"));
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_update_is_file_owner
|
||||
BEFORE INSERT OR UPDATE ON file_state
|
||||
FOR EACH ROW EXECUTE FUNCTION update_is_file_owner();
|
||||
|
||||
-- When the file table is updated, we want to check if the user is the owner of the file
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_file_state_on_file_change() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE file_state
|
||||
SET "isFileOwner" = (file_state."userId" = NEW."ownerId")
|
||||
WHERE file_state."fileId" = NEW.id;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trigger_update_file_state_on_file_change
|
||||
AFTER UPDATE ON file
|
||||
FOR EACH ROW EXECUTE FUNCTION update_file_state_on_file_change();
|
||||
|
||||
-- popluating the isFileOwner column with no-op update
|
||||
UPDATE file_state
|
||||
SET "userId" = file_state."userId";
|
||||
@@ -0,0 +1,18 @@
|
||||
-- Purpose: Add a trigger to update the updatedAt column in the file table.
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_file_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- updatedAt is a unix timestamp in milliseconds
|
||||
NEW."updatedAt" := CAST(EXTRACT(EPOCH FROM now()) * 1000 AS BIGINT);
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- When a file is inserted or updated, update the updatedAt column in the file table
|
||||
|
||||
CREATE TRIGGER update_file_updatedAt
|
||||
BEFORE UPDATE ON "file"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD.* IS DISTINCT FROM NEW.*)
|
||||
EXECUTE FUNCTION update_file_updated_at();
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Purpose: Add a column to the file table that indicates whether the file has been soft deleted.
|
||||
|
||||
ALTER TABLE public.file
|
||||
ADD COLUMN "isDeleted" BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Purpose: update the file table to include the owner's name and avatar.
|
||||
|
||||
ALTER TABLE "file"
|
||||
ADD COLUMN "ownerName" VARCHAR DEFAULT '' NOT NULL;
|
||||
ALTER TABLE "file"
|
||||
ADD COLUMN "ownerAvatar" VARCHAR DEFAULT '' NOT NULL;
|
||||
|
||||
UPDATE "file"
|
||||
SET "ownerName" = u."name",
|
||||
"ownerAvatar" = u."avatar"
|
||||
FROM public."user" u
|
||||
WHERE u."id" = "file"."ownerId";
|
||||
|
||||
-- When the user's name or avatar is updated, update the ownerName and ownerAvatar columns in the file table
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_file_owner_details() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE "file"
|
||||
SET "ownerName" = NEW.name,
|
||||
"ownerAvatar" = NEW.avatar
|
||||
WHERE "ownerId" = NEW.id;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- When a file is inserted or updated, update the ownerName and ownerAvatar columns in the file table
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_file_owner_details() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE "file"
|
||||
SET "ownerName" = u."name",
|
||||
"ownerAvatar" = u."avatar"
|
||||
FROM public."user" u
|
||||
WHERE u."id" = NEW."ownerId" AND "file"."id" = NEW."id";
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- When the user's name or avatar is updated, update the ownerName and ownerAvatar columns in the file table
|
||||
|
||||
CREATE TRIGGER "update_file_owner_details_trigger"
|
||||
AFTER UPDATE OF "name", "avatar" ON public."user"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD."name" IS DISTINCT FROM NEW."name" OR OLD."avatar" IS DISTINCT FROM NEW."avatar")
|
||||
EXECUTE FUNCTION update_file_owner_details();
|
||||
|
||||
-- When a file is inserted or updated, update the ownerName and ownerAvatar columns in the file table
|
||||
|
||||
CREATE TRIGGER "set_file_owner_details_trigger"
|
||||
AFTER INSERT OR UPDATE OF "ownerId" ON "file"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_file_owner_details();
|
||||
4
apps/dotcom/zero-cache/migrations/008_add_pinned.sql
Normal file
4
apps/dotcom/zero-cache/migrations/008_add_pinned.sql
Normal file
@@ -0,0 +1,4 @@
|
||||
-- Purpose: Add an isPinned column to the file_state table that indicates whether the user has pinned the file.
|
||||
|
||||
ALTER TABLE file_state
|
||||
ADD COLUMN "isPinned" BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Purpose: Add an allowAnalyticsCookie column to the user table that indicates whether the user has
|
||||
-- consented to analytics cookies. 3 possible values:
|
||||
-- - true: the user has consented to analytics cookies
|
||||
-- - false: the user has not consented to analytics cookies
|
||||
-- - null: the user has not yet been asked to consent to analytics cookies
|
||||
|
||||
ALTER TABLE "user"
|
||||
ADD COLUMN "allowAnalyticsCookie" BOOLEAN DEFAULT NULL;
|
||||
8
apps/dotcom/zero-cache/migrations/010_add_asset_info.sql
Normal file
8
apps/dotcom/zero-cache/migrations/010_add_asset_info.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
CREATE TABLE "asset" (
|
||||
"objectName" VARCHAR PRIMARY KEY,
|
||||
"fileId" VARCHAR NOT NULL,
|
||||
"uploadedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
CONSTRAINT "fk_file"
|
||||
FOREIGN KEY ("fileId")
|
||||
REFERENCES "file"("id")
|
||||
);
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Used for tracking the creation source of a file. Can be a duplication of an existing file,
|
||||
-- or slurping of a legacy file (multiplayer, readonly, snapshot).
|
||||
-- This is only used immediately after creation and should then be set to null.
|
||||
ALTER TABLE "file"
|
||||
ADD COLUMN "createSource" VARCHAR;
|
||||
24
apps/dotcom/zero-cache/migrations/012_prevent_deadlocks.sql
Normal file
24
apps/dotcom/zero-cache/migrations/012_prevent_deadlocks.sql
Normal file
@@ -0,0 +1,24 @@
|
||||
CREATE OR REPLACE FUNCTION update_file_state_on_file_change() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF OLD."ownerId" IS DISTINCT FROM NEW."ownerId" THEN
|
||||
UPDATE file_state
|
||||
SET "isFileOwner" = (file_state."userId" = NEW."ownerId")
|
||||
WHERE file_state."fileId" = NEW.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_is_file_owner() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
NEW."isFileOwner" := (NEW."userId" = (SELECT "ownerId" FROM file WHERE file.id = NEW."fileId"));
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
IF NEW."userId" IS DISTINCT FROM OLD."userId" THEN
|
||||
NEW."isFileOwner" := (NEW."userId" = (SELECT "ownerId" FROM file WHERE file.id = NEW."fileId"));
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,17 @@
|
||||
-- In order to correctly dispatch file delete events, we need to know the owner of the file
|
||||
-- Prior to this migration we did already store the ownerId on the file table, but it was not part of the primary key
|
||||
-- so it was not present in the replication events for deletions.
|
||||
-- This migration adds the ownerId to the primary key of the file table, and recreates the dependent foreign keys
|
||||
-- which had to be deleted when we dropped the file_pkey constraint. Alas there's no way to alter a primary key constraint in place.
|
||||
-- To maintain the invariant that the file id is unique, we add a new uniqueness constraint on the id column.
|
||||
|
||||
ALTER TABLE "file"
|
||||
DROP CONSTRAINT "file_pkey" CASCADE,
|
||||
ADD PRIMARY KEY ("id", "ownerId"),
|
||||
ADD CONSTRAINT "file_id_unique" UNIQUE ("id");
|
||||
|
||||
ALTER TABLE "asset"
|
||||
ADD CONSTRAINT "asset_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "file" ("id") ON DELETE CASCADE;
|
||||
|
||||
ALTER TABLE "file_state"
|
||||
ADD CONSTRAINT "file_state_fileId_fkey" FOREIGN KEY ("fileId") REFERENCES "file" ("id") ON DELETE CASCADE;
|
||||
@@ -0,0 +1,5 @@
|
||||
-- In order to correctly clean up resources for deleted files, we need to know the publishSlug of the file.
|
||||
|
||||
ALTER TABLE "file"
|
||||
DROP CONSTRAINT "file_pkey",
|
||||
ADD PRIMARY KEY ("id", "ownerId", "publishedSlug");
|
||||
1
apps/dotcom/zero-cache/migrations/015_full_replicas.sql
Normal file
1
apps/dotcom/zero-cache/migrations/015_full_replicas.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE file REPLICA IDENTITY FULL;
|
||||
@@ -0,0 +1 @@
|
||||
CREATE PUBLICATION zero_data FOR TABLE file, file_state, public.user;
|
||||
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE "asset"
|
||||
ADD COLUMN "userId" VARCHAR NULL;
|
||||
|
||||
ALTER TABLE "asset"
|
||||
ADD CONSTRAINT "fk_user"
|
||||
FOREIGN KEY ("userId")
|
||||
REFERENCES "user"("id");
|
||||
@@ -0,0 +1,3 @@
|
||||
-- need to do this so we get isFileOwner when deleting a file
|
||||
-- and that lets us property determine if a topic subscription should be removed
|
||||
ALTER TABLE public."file_state" REPLICA IDENTITY FULL;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Purpose: Add an areKeyboardShortcutsEnabled column to the user table that indicates whether the user
|
||||
-- wants keyboard shortcuts enabled. 3 possible values:
|
||||
-- - true: the user wants keyboard shortcuts
|
||||
-- - false: the user does not want keyboard shortcuts
|
||||
-- - null: the user has not yet set the preference
|
||||
|
||||
ALTER TABLE "user"
|
||||
ADD COLUMN "areKeyboardShortcutsEnabled" BOOLEAN DEFAULT NULL;
|
||||
@@ -0,0 +1,8 @@
|
||||
-- Purpose: Add a showUiLabels column to the user table that indicates whether the user
|
||||
-- wants UI labels displayed. 3 possible values:
|
||||
-- - true: the user wants UI labels shown
|
||||
-- - false: the user does not want UI labels shown
|
||||
-- - null: the user has not yet set the preference
|
||||
|
||||
ALTER TABLE "user"
|
||||
ADD COLUMN "showUiLabels" BOOLEAN DEFAULT NULL;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Purpose: Renames showUiLabels column to enhancedA11yMode column in the user table
|
||||
|
||||
ALTER TABLE "user"
|
||||
RENAME COLUMN "showUiLabels" TO "enhancedA11yMode";
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE public."user"
|
||||
ADD COLUMN "inputMode" VARCHAR DEFAULT NULL;
|
||||
|
||||
ALTER TABLE public."user"
|
||||
ADD CONSTRAINT "inputMode_check" CHECK ("inputMode" IS NULL OR "inputMode" IN ('trackpad', 'mouse'));
|
||||
451
apps/dotcom/zero-cache/migrations/023_groups.sql
Normal file
451
apps/dotcom/zero-cache/migrations/023_groups.sql
Normal file
@@ -0,0 +1,451 @@
|
||||
----------------------------------
|
||||
--- CREATE NEW DATA MODEL BITS ---
|
||||
----------------------------------
|
||||
|
||||
CREATE TABLE "group" (
|
||||
"id" TEXT PRIMARY KEY,
|
||||
"name" TEXT NOT NULL,
|
||||
"inviteSecret" TEXT,
|
||||
"isDeleted" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
"createdAt" BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000,
|
||||
"updatedAt" BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000
|
||||
);
|
||||
|
||||
CREATE INDEX "group_inviteSecret_idx" ON "group" ("inviteSecret");
|
||||
|
||||
CREATE TABLE "group_user" (
|
||||
"userId" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"createdAt" BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000,
|
||||
"updatedAt" BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000,
|
||||
"role" TEXT NOT NULL CHECK ("role" IN ('admin', 'owner')),
|
||||
-- index for the user's own sidebar group ordering
|
||||
"index" TEXT NOT NULL,
|
||||
-- 'computed' name and color fields are populated by a trigger to share
|
||||
-- some of the user's details with the other group members
|
||||
"userName" TEXT NOT NULL,
|
||||
"userColor" TEXT NOT NULL DEFAULT '#000000',
|
||||
PRIMARY KEY ("userId", "groupId"),
|
||||
FOREIGN KEY ("userId") REFERENCES public."user" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("groupId") REFERENCES public."group" ("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE "group_file" (
|
||||
"fileId" TEXT NOT NULL,
|
||||
"groupId" TEXT NOT NULL,
|
||||
"createdAt" BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000,
|
||||
"updatedAt" BIGINT NOT NULL DEFAULT EXTRACT(EPOCH FROM NOW()) * 1000,
|
||||
"index" TEXT,
|
||||
PRIMARY KEY ("fileId", "groupId"),
|
||||
FOREIGN KEY ("fileId") REFERENCES public."file" ("id") ON DELETE CASCADE,
|
||||
FOREIGN KEY ("groupId") REFERENCES public."group" ("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- add owningGroupId column
|
||||
ALTER TABLE public."file" ADD COLUMN "owningGroupId" TEXT REFERENCES public."group" ("id") ON DELETE CASCADE;
|
||||
|
||||
-- When the user's name or color is updated, update the userName and userColor columns in the group_user table
|
||||
CREATE OR REPLACE FUNCTION update_group_user_details() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE "group_user"
|
||||
SET "userName" = NEW.name,
|
||||
"userColor" = COALESCE(NEW.color, '#000000')
|
||||
WHERE "userId" = NEW.id;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- When a group_user is inserted or updated, update the userName and userColor columns
|
||||
CREATE OR REPLACE FUNCTION set_group_user_details() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE "group_user"
|
||||
SET "userName" = u."name",
|
||||
"userColor" = COALESCE(u."color", '#000000')
|
||||
FROM public."user" u
|
||||
WHERE u."id" = NEW."userId" AND "group_user"."userId" = NEW."userId" AND "group_user"."groupId" = NEW."groupId";
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- When the user's name or color is updated, update the userName and userColor columns in the group_user table
|
||||
CREATE TRIGGER "update_group_user_details_trigger"
|
||||
AFTER UPDATE OF "name", "color" ON public."user"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD."name" IS DISTINCT FROM NEW."name" OR OLD."color" IS DISTINCT FROM NEW."color")
|
||||
EXECUTE FUNCTION update_group_user_details();
|
||||
|
||||
-- When a group_user is inserted or updated, update the userName and userEmail columns
|
||||
CREATE TRIGGER "set_group_user_details_trigger"
|
||||
AFTER INSERT OR UPDATE OF "userId" ON "group_user"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_group_user_details();
|
||||
|
||||
ALTER PUBLICATION zero_data ADD TABLE public."group", public."group_user", public."group_file";
|
||||
|
||||
-- Remove ownerId and publishedSlug from file table primary key
|
||||
ALTER TABLE public."file" DROP CONSTRAINT "file_pkey";
|
||||
ALTER TABLE public."file" ADD PRIMARY KEY ("id");
|
||||
|
||||
ALTER TABLE public."file" ALTER COLUMN "ownerId" DROP NOT NULL;
|
||||
|
||||
-- Add constraint to ensure exactly one of ownerId or owningGroupId is set
|
||||
-- This prevents files from being owned by both a user and a group simultaneously
|
||||
-- XOR logic: one must be null and one must be non-null
|
||||
ALTER TABLE public."file" ADD CONSTRAINT "file_owner_xor_check"
|
||||
CHECK (
|
||||
(("ownerId" IS NULL) != ("owningGroupId" IS NULL)) -- XOR: one must be null and one must be non-null
|
||||
);
|
||||
|
||||
CREATE INDEX "file_owning_group_id_idx" ON public."file" ("owningGroupId");
|
||||
|
||||
|
||||
-- Create a trigger to update file owner details when group name changes
|
||||
-- This ensures the denormalized ownerName field stays in sync with group name changes
|
||||
CREATE OR REPLACE FUNCTION update_file_group_details() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE "file"
|
||||
SET "ownerName" = NEW.name,
|
||||
"ownerAvatar" = ''
|
||||
WHERE "owningGroupId" = NEW.id;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create a trigger to set file owner details when file ownership changes
|
||||
-- This handles both user ownership and group ownership scenarios
|
||||
CREATE OR REPLACE FUNCTION set_file_owner_details() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- If the file is owned by a user, set user details
|
||||
IF NEW."ownerId" IS NOT NULL THEN
|
||||
UPDATE "file"
|
||||
SET "ownerName" = u."name",
|
||||
"ownerAvatar" = u."avatar"
|
||||
FROM public."user" u
|
||||
WHERE u."id" = NEW."ownerId" AND "file"."id" = NEW."id";
|
||||
END IF;
|
||||
|
||||
-- If the file is owned by a group, set group details
|
||||
IF NEW."owningGroupId" IS NOT NULL THEN
|
||||
UPDATE "file"
|
||||
SET "ownerName" = g."name",
|
||||
"ownerAvatar" = ''
|
||||
FROM public."group" g
|
||||
WHERE g."id" = NEW."owningGroupId" AND "file"."id" = NEW."id";
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create a trigger to update file owner details when group name is updated
|
||||
CREATE TRIGGER "update_file_group_details_trigger"
|
||||
AFTER UPDATE OF "name" ON public."group"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD."name" IS DISTINCT FROM NEW."name")
|
||||
EXECUTE FUNCTION update_file_group_details();
|
||||
|
||||
-- Create a trigger to set file owner details when file ownership changes
|
||||
CREATE OR REPLACE TRIGGER "set_file_owner_details_trigger"
|
||||
AFTER INSERT OR UPDATE OF "ownerId", "owningGroupId" ON "file"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_file_owner_details();
|
||||
|
||||
-- Create a function to clean up related records when a file is marked as deleted
|
||||
CREATE OR REPLACE FUNCTION cleanup_deleted_file() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Delete any file states for this file
|
||||
DELETE FROM "file_state"
|
||||
WHERE "fileId" = NEW."id";
|
||||
|
||||
-- Delete any group file associations for this file
|
||||
DELETE FROM "group_file"
|
||||
WHERE "fileId" = NEW."id";
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create a trigger to clean up related records when a file is marked as deleted
|
||||
CREATE TRIGGER "cleanup_deleted_file_trigger"
|
||||
AFTER UPDATE OF "isDeleted" ON public."file"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD."isDeleted" = false AND NEW."isDeleted" = true)
|
||||
EXECUTE FUNCTION cleanup_deleted_file();
|
||||
|
||||
-- Let's delete all the dangling file_state records (there should only be a handful, I already
|
||||
-- deleted the rows manually in prod)
|
||||
WITH dangling_file_states AS (
|
||||
SELECT fs."fileId", fs."userId"
|
||||
FROM public."file" f INNER JOIN public."file_state" fs ON f."id" = fs."fileId"
|
||||
WHERE f."isDeleted" = true
|
||||
)
|
||||
DELETE FROM public."file_state"
|
||||
WHERE ("fileId", "userId") IN (SELECT "fileId", "userId" FROM dangling_file_states);
|
||||
|
||||
-- Create a function to clean up group associations when a group is marked as deleted
|
||||
CREATE OR REPLACE FUNCTION cleanup_deleted_group() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Delete all group_user associations for this group
|
||||
DELETE FROM "group_user"
|
||||
WHERE "groupId" = NEW."id";
|
||||
|
||||
-- Delete all group_file associations for this group
|
||||
DELETE FROM "group_file"
|
||||
WHERE "groupId" = NEW."id";
|
||||
|
||||
-- Mark all files owned by this group as deleted
|
||||
UPDATE "file"
|
||||
SET "isDeleted" = true
|
||||
WHERE "owningGroupId" = NEW."id";
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create a trigger to clean up group associations when a group is marked as deleted
|
||||
CREATE TRIGGER "cleanup_deleted_group_trigger"
|
||||
AFTER UPDATE OF "isDeleted" ON public."group"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD."isDeleted" = false AND NEW."isDeleted" = true)
|
||||
EXECUTE FUNCTION cleanup_deleted_group();
|
||||
|
||||
-- Create a function to clean up file states when a user leaves a group
|
||||
-- This removes access to non-shared files that are owned by the group the user just left
|
||||
CREATE OR REPLACE FUNCTION cleanup_group_user_file_states() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Delete file_state records for files that are:
|
||||
-- 1. Owned by the group the user just left (OLD.groupId)
|
||||
-- 2. Not shared (so the user no longer has access)
|
||||
DELETE FROM "file_state"
|
||||
WHERE "userId" = OLD."userId"
|
||||
AND "fileId" IN (
|
||||
SELECT "id" FROM "file"
|
||||
WHERE "owningGroupId" = OLD."groupId"
|
||||
AND "shared" = false
|
||||
);
|
||||
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create a trigger to clean up file states when a user leaves a group
|
||||
CREATE TRIGGER "cleanup_group_user_file_states_trigger"
|
||||
AFTER DELETE ON public."group_user"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION cleanup_group_user_file_states();
|
||||
|
||||
|
||||
-- Create a function to update the updatedAt timestamp for group tables
|
||||
CREATE OR REPLACE FUNCTION update_group_timestamp() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW."updatedAt" = EXTRACT(EPOCH FROM NOW()) * 1000;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create a function to update the updatedAt timestamp for group_user table
|
||||
CREATE OR REPLACE FUNCTION update_group_user_timestamp() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW."updatedAt" = EXTRACT(EPOCH FROM NOW()) * 1000;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create a function to update the updatedAt timestamp for group_file table
|
||||
CREATE OR REPLACE FUNCTION update_group_file_timestamp() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW."updatedAt" = EXTRACT(EPOCH FROM NOW()) * 1000;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create triggers to automatically update timestamps
|
||||
CREATE TRIGGER "update_group_timestamp_trigger"
|
||||
BEFORE UPDATE ON public."group"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_group_timestamp();
|
||||
|
||||
CREATE TRIGGER "update_group_user_timestamp_trigger"
|
||||
BEFORE UPDATE ON public."group_user"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_group_user_timestamp();
|
||||
|
||||
CREATE TRIGGER "update_group_file_timestamp_trigger"
|
||||
BEFORE UPDATE ON public."group_file"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_group_file_timestamp();
|
||||
|
||||
-- Create a function to sync home group name with user name on update
|
||||
-- When a user's name is updated, update the corresponding home group's name
|
||||
CREATE OR REPLACE FUNCTION update_home_group_name() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Update the group name where the group id matches the user id (home group)
|
||||
UPDATE "group"
|
||||
SET "name" = NEW.name
|
||||
WHERE "id" = NEW.id;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create a trigger to sync home group name when user name is updated
|
||||
CREATE TRIGGER "update_home_group_name_trigger"
|
||||
AFTER UPDATE OF "name" ON public."user"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD."name" IS DISTINCT FROM NEW."name")
|
||||
EXECUTE FUNCTION update_home_group_name();
|
||||
|
||||
-- Create a function to initialize group name from user name on group creation
|
||||
-- When a group is created with an id matching a user id, set the group name to the user's name
|
||||
CREATE OR REPLACE FUNCTION initialize_home_group_name() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- If there's a user with the same id as the group, update the group name to match
|
||||
UPDATE "group"
|
||||
SET "name" = u."name"
|
||||
FROM public."user" u
|
||||
WHERE u."id" = NEW."id" AND "group"."id" = NEW."id";
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create a trigger to initialize group name from user name on group creation
|
||||
CREATE TRIGGER "initialize_home_group_name_trigger"
|
||||
AFTER INSERT ON public."group"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION initialize_home_group_name();
|
||||
|
||||
-- Create a function to validate group_file associations
|
||||
CREATE OR REPLACE FUNCTION validate_group_file_association() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Check that the file is either shared or owned by the group
|
||||
-- This prevents linking private files that the group doesn't own
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM "file" f
|
||||
WHERE f."id" = NEW."fileId"
|
||||
AND (f."shared" = true OR f."owningGroupId" = NEW."groupId")
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Cannot link file to group: file must be shared or owned by the group';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Create a trigger to validate group_file associations before insertion
|
||||
CREATE TRIGGER "validate_group_file_association_trigger"
|
||||
BEFORE INSERT ON public."group_file"
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION validate_group_file_association();
|
||||
|
||||
-- ownerId becomes nullable so we need to update the file_state updater fns to handle NULLs
|
||||
CREATE OR REPLACE FUNCTION update_file_state_on_file_change() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF OLD."ownerId" IS DISTINCT FROM NEW."ownerId" THEN
|
||||
UPDATE file_state
|
||||
SET "isFileOwner" = COALESCE(file_state."userId" = NEW."ownerId", FALSE)
|
||||
WHERE file_state."fileId" = NEW.id;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION update_is_file_owner() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
NEW."isFileOwner" := COALESCE(NEW."userId" = (SELECT "ownerId" FROM file WHERE file.id = NEW."fileId"), FALSE);
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
IF NEW."userId" IS DISTINCT FROM OLD."userId" THEN
|
||||
NEW."isFileOwner" := COALESCE(NEW."userId" = (SELECT "ownerId" FROM file WHERE file.id = NEW."fileId"), FALSE);
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- Migration function to move a user from legacy file_state-based model to group_file-based model
|
||||
-- Usage: SELECT migrate_user_to_groups('user-id-here');
|
||||
CREATE OR REPLACE FUNCTION migrate_user_to_groups(target_user_id TEXT, invite_secret TEXT)
|
||||
RETURNS TABLE (
|
||||
files_migrated INTEGER,
|
||||
pinned_files_migrated INTEGER,
|
||||
flag_added BOOLEAN
|
||||
) AS $$
|
||||
DECLARE
|
||||
v_files_migrated INTEGER := 0;
|
||||
v_pinned_files_migrated INTEGER := 0;
|
||||
v_current_flags TEXT;
|
||||
v_file_record RECORD;
|
||||
v_now BIGINT := EXTRACT(EPOCH FROM NOW()) * 1000;
|
||||
v_lock_key BIGINT;
|
||||
BEGIN
|
||||
-- Check if user exists
|
||||
IF NOT EXISTS (SELECT 1 FROM public."user" WHERE id = target_user_id) THEN
|
||||
RAISE EXCEPTION 'User % does not exist', target_user_id;
|
||||
END IF;
|
||||
|
||||
-- Check if user already has groups flag
|
||||
SELECT flags INTO v_current_flags FROM public."user" WHERE id = target_user_id;
|
||||
IF v_current_flags LIKE '%groups_backend%' THEN
|
||||
RAISE NOTICE 'User % already has groups flag, skipping migration', target_user_id;
|
||||
RETURN QUERY SELECT 0, 0, FALSE;
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- Acquire an advisory lock based on the user ID to prevent mutations during migration
|
||||
-- Convert user ID string to a bigint for the lock key using hashtext
|
||||
-- Using xact lock so it's automatically released when transaction ends
|
||||
v_lock_key := hashtext(target_user_id);
|
||||
RAISE NOTICE 'Acquiring advisory lock % for user %', v_lock_key, target_user_id;
|
||||
PERFORM pg_advisory_xact_lock(v_lock_key);
|
||||
|
||||
-- Lock acquired, now we can safely migrate
|
||||
RAISE NOTICE 'Lock acquired, starting migration for user %', target_user_id;
|
||||
|
||||
-- Create a home group for the user
|
||||
INSERT INTO public."group" ("id", "name", "createdAt", "updatedAt", "isDeleted", "inviteSecret")
|
||||
VALUES (target_user_id, target_user_id, v_now, v_now, FALSE, invite_secret)
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Make the user a member of the home group
|
||||
INSERT INTO public."group_user" ("userId", "groupId", "createdAt", "updatedAt", "role", "index", "userName", "userColor")
|
||||
VALUES (target_user_id, target_user_id, v_now, v_now, 'owner', 'a1', '', '')
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- For any files owned by the user, change the owningGroupId to the home group and set the ownerId to null
|
||||
UPDATE public."file"
|
||||
SET "owningGroupId" = target_user_id,
|
||||
"ownerId" = NULL
|
||||
WHERE "ownerId" = target_user_id;
|
||||
|
||||
-- For any files that the user has access to, create a group_file entry in the home group
|
||||
INSERT INTO public."group_file" ("fileId", "groupId", "createdAt", "updatedAt")
|
||||
SELECT fs."fileId", target_user_id, COALESCE(fs."firstVisitAt", f."createdAt", v_now), COALESCE(fs."lastEditAt", fs."firstVisitAt", f."updatedAt", f."createdAt", v_now)
|
||||
FROM public."file_state" fs JOIN public."file" f ON fs."fileId" = f."id"
|
||||
WHERE fs."userId" = target_user_id
|
||||
ON CONFLICT DO NOTHING;
|
||||
|
||||
-- Update indexes for the group_file entries
|
||||
UPDATE public."group_file" gf
|
||||
SET "index" = 'a' || fs.i::text
|
||||
FROM (
|
||||
SELECT "fileId", ROW_NUMBER() over (ORDER BY COALESCE("lastEditAt", "firstVisitAt", 0) DESC) AS i
|
||||
FROM public."file_state"
|
||||
WHERE "userId" = target_user_id
|
||||
AND "isPinned" = TRUE
|
||||
) AS fs
|
||||
WHERE gf."fileId" = fs."fileId" AND gf."groupId" = target_user_id;
|
||||
|
||||
-- Add 'groups_backend' flag to user
|
||||
IF v_current_flags IS NULL OR v_current_flags = '' THEN
|
||||
UPDATE public."user" SET flags = 'groups_backend' WHERE id = target_user_id;
|
||||
ELSE
|
||||
UPDATE public."user" SET flags = v_current_flags || ' groups_backend' WHERE id = target_user_id;
|
||||
END IF;
|
||||
|
||||
RAISE NOTICE 'Successfully migrated user % to groups model. Files: %, Pinned: %',
|
||||
target_user_id, v_files_migrated, v_pinned_files_migrated;
|
||||
|
||||
RETURN QUERY SELECT v_files_migrated, v_pinned_files_migrated, TRUE;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
15
apps/dotcom/zero-cache/migrations/024_add_fairies.sql
Normal file
15
apps/dotcom/zero-cache/migrations/024_add_fairies.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
|
||||
CREATE TABLE user_fairies (
|
||||
"userId" VARCHAR PRIMARY KEY,
|
||||
"fairies" VARCHAR,
|
||||
CONSTRAINT user_fairies_user_id_fkey FOREIGN KEY ("userId") REFERENCES public."user"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE file_fairies (
|
||||
"fileId" VARCHAR,
|
||||
"userId" VARCHAR,
|
||||
"fairyState" VARCHAR,
|
||||
PRIMARY KEY ("fileId", "userId"),
|
||||
CONSTRAINT file_fairies_file_id_fkey FOREIGN KEY ("fileId") REFERENCES public."file"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT file_fairies_user_id_fkey FOREIGN KEY ("userId") REFERENCES public."user"("id") ON DELETE CASCADE
|
||||
);
|
||||
13
apps/dotcom/zero-cache/migrations/025_fairy_gating.sql
Normal file
13
apps/dotcom/zero-cache/migrations/025_fairy_gating.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
-- Add fairy access control columns to user_fairies table
|
||||
ALTER TABLE user_fairies
|
||||
ADD COLUMN "fairyLimit" INTEGER,
|
||||
ADD COLUMN "fairyAccessExpiresAt" BIGINT;
|
||||
|
||||
-- Create fairy invite table
|
||||
CREATE TABLE fairy_invite (
|
||||
id VARCHAR PRIMARY KEY,
|
||||
"fairyLimit" INTEGER NOT NULL,
|
||||
"maxUses" INTEGER NOT NULL,
|
||||
"currentUses" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" BIGINT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE user_fairies
|
||||
ADD COLUMN "weeklyUsage" JSONB DEFAULT '{}'::jsonb;
|
||||
13
apps/dotcom/zero-cache/migrations/027_add_fairy_messages.sql
Normal file
13
apps/dotcom/zero-cache/migrations/027_add_fairy_messages.sql
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
CREATE TABLE file_fairy_messages (
|
||||
"id" VARCHAR PRIMARY KEY,
|
||||
"fileId" VARCHAR NOT NULL,
|
||||
"userId" VARCHAR NOT NULL,
|
||||
"message" VARCHAR NOT NULL,
|
||||
"createdAt" BIGINT NOT NULL,
|
||||
"updatedAt" BIGINT NOT NULL,
|
||||
CONSTRAINT file_fairy_messages_file_id_fkey FOREIGN KEY ("fileId") REFERENCES public."file"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT file_fairy_messages_user_id_fkey FOREIGN KEY ("userId") REFERENCES public."user"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX file_fairy_messages_file_user_idx ON file_fairy_messages("fileId", "userId");
|
||||
@@ -0,0 +1,3 @@
|
||||
ALTER TABLE fairy_invite
|
||||
ADD COLUMN description TEXT,
|
||||
ADD COLUMN "redeemedBy" JSONB DEFAULT '[]'::jsonb;
|
||||
@@ -0,0 +1,21 @@
|
||||
|
||||
CREATE TABLE paddle_transactions (
|
||||
"eventId" VARCHAR PRIMARY KEY,
|
||||
"transactionId" VARCHAR NOT NULL,
|
||||
"eventType" VARCHAR NOT NULL,
|
||||
"status" VARCHAR NOT NULL,
|
||||
"userId" VARCHAR,
|
||||
"processed" BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
"processedAt" BIGINT,
|
||||
"processingError" TEXT,
|
||||
"eventData" JSONB NOT NULL,
|
||||
"occurredAt" BIGINT NOT NULL,
|
||||
"receivedAt" BIGINT NOT NULL,
|
||||
"updatedAt" BIGINT NOT NULL
|
||||
);
|
||||
|
||||
CREATE INDEX paddle_transactions_processing_idx ON paddle_transactions("transactionId", "eventType", "processed") WHERE "eventType" = 'transaction.completed';
|
||||
|
||||
CREATE INDEX paddle_transactions_user_idx ON paddle_transactions("userId", "occurredAt") WHERE "userId" IS NOT NULL;
|
||||
|
||||
CREATE INDEX paddle_transactions_transaction_id_idx ON paddle_transactions("transactionId", "occurredAt");
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE user_fairies
|
||||
ADD COLUMN "weeklyLimit" INTEGER DEFAULT 25;
|
||||
@@ -0,0 +1,5 @@
|
||||
ALTER TABLE public."user"
|
||||
ADD COLUMN "isZoomDirectionInverted" BOOLEAN DEFAULT NULL;
|
||||
|
||||
ALTER TABLE public."user"
|
||||
ADD CONSTRAINT "isZoomDirectionInverted_check" CHECK ("isZoomDirectionInverted" IS NULL OR "isZoomDirectionInverted" IN (TRUE, FALSE));
|
||||
@@ -0,0 +1,2 @@
|
||||
CREATE INDEX "group_user_group_id_idx" ON public."group_user" ("groupId");
|
||||
CREATE INDEX "group_file_group_id_idx" ON public."group_file" ("groupId");
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Enable Zero's manual DDL detection so it trusts the migration runner's
|
||||
-- update_schemas() calls instead of event triggers (Supabase doesn't fire them
|
||||
-- for ALTER PUBLICATION). Guarded because zero_0 doesn't exist until Zero boots,
|
||||
-- which happens after migrations on a fresh database.
|
||||
-- https://zero.rocicorp.dev/docs/connecting-to-postgres#schema-change-hooks
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('zero_0."shardConfig"') IS NOT NULL THEN
|
||||
UPDATE zero_0."shardConfig" SET "ddlDetection" = true;
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,13 @@
|
||||
-- Rename the non-owner group role from 'admin' to 'member'.
|
||||
-- The 'admin' label was misleading: the role confers no admin-level
|
||||
-- capabilities, it's a standard group member.
|
||||
|
||||
-- Drop the existing CHECK constraint. It was created as an inline column
|
||||
-- constraint in 023_groups.sql, so Postgres auto-named it "group_user_role_check".
|
||||
ALTER TABLE public."group_user" DROP CONSTRAINT IF EXISTS "group_user_role_check";
|
||||
|
||||
-- Rewrite existing rows to the new role value.
|
||||
UPDATE public."group_user" SET "role" = 'member' WHERE "role" = 'admin';
|
||||
|
||||
-- Re-add the CHECK constraint with the new allowed values.
|
||||
ALTER TABLE public."group_user" ADD CONSTRAINT "group_user_role_check" CHECK ("role" IN ('member', 'owner'));
|
||||
@@ -0,0 +1,58 @@
|
||||
-- Fix guest cleanup when a group-owned file is unshared.
|
||||
--
|
||||
-- The original delete_file_states trigger (000_seed.sql) keys on
|
||||
-- `OLD."ownerId" != "userId"` to decide which file_states are "guests". The groups
|
||||
-- backend (023_groups.sql) introduced group-owned files where `ownerId` is NULL
|
||||
-- (see the ownerId XOR owningGroupId constraint). For those files
|
||||
-- `NULL != "userId"` evaluates to NULL, so the DELETE matched no rows and guest
|
||||
-- file_states lingered after unsharing — leaving the now-private file syncing to
|
||||
-- ex-guests and showing in their recent files.
|
||||
--
|
||||
-- Visiting a shared file also links it into the visitor's home group via a
|
||||
-- group_file row (home group id == user id by convention), which is what puts
|
||||
-- the file in their recent files. Nothing cleaned those links up on unshare
|
||||
-- either — so the file's name kept showing in an ex-guest's recent files even
|
||||
-- once their file_state and access were gone.
|
||||
--
|
||||
-- This replaces the function so it cleans up both records for both ownership
|
||||
-- models:
|
||||
-- * file_state: delete states for anyone who isn't the legacy owner
|
||||
-- (NULL-safe) or a current member of the owning group;
|
||||
-- * group_file: delete the file's links except the owning group's own row
|
||||
-- and home-group links of users who keep access (the legacy owner /
|
||||
-- owning-group members).
|
||||
-- The trigger binding (file_shared_update) is unchanged.
|
||||
CREATE OR REPLACE FUNCTION delete_file_states() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF OLD.shared = TRUE AND NEW.shared = FALSE THEN
|
||||
DELETE FROM file_state fs
|
||||
WHERE fs."fileId" = OLD.id
|
||||
-- not the legacy owner (IS DISTINCT FROM is NULL-safe, so a NULL ownerId
|
||||
-- never accidentally protects a guest the way `!=` did)
|
||||
AND OLD."ownerId" IS DISTINCT FROM fs."userId"
|
||||
-- not a current member of the owning group (no-op for legacy files, where
|
||||
-- owningGroupId is NULL and the subquery returns nothing)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM group_user gu
|
||||
WHERE gu."groupId" = OLD."owningGroupId"
|
||||
AND gu."userId" = fs."userId"
|
||||
);
|
||||
|
||||
DELETE FROM group_file gf
|
||||
WHERE gf."fileId" = OLD.id
|
||||
-- never the owning group's own row: that's where the file lives
|
||||
AND gf."groupId" IS DISTINCT FROM OLD."owningGroupId"
|
||||
-- not the legacy owner's home-group link (home group id == user id)
|
||||
AND gf."groupId" IS DISTINCT FROM OLD."ownerId"
|
||||
-- not a home-group link of a current owning-group member (no-op for
|
||||
-- legacy files, where owningGroupId is NULL and the subquery returns
|
||||
-- nothing)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM group_user gu
|
||||
WHERE gu."groupId" = OLD."owningGroupId"
|
||||
AND gu."userId" = gf."groupId"
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
14
apps/dotcom/zero-cache/migrations/035_welcome_template.sql
Normal file
14
apps/dotcom/zero-cache/migrations/035_welcome_template.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Points at the file whose published snapshot seeds a new workspace's first file (the
|
||||
-- "welcome template"). Set by an admin action; read by the sync worker when it materializes
|
||||
-- a file created with createSource 'welcome'. When no row is present (fresh env, before an
|
||||
-- admin has marked one) the worker falls back to a committed default snapshot.
|
||||
--
|
||||
-- Singleton: the boolean primary key only permits id = TRUE, so there is at most one row.
|
||||
-- Deliberately NOT added to the Zero publication — this is worker-side config, not client
|
||||
-- sync state, so the client never replicates it.
|
||||
CREATE TABLE welcome_template (
|
||||
id BOOLEAN PRIMARY KEY DEFAULT TRUE CHECK (id = TRUE),
|
||||
"fileId" VARCHAR NOT NULL,
|
||||
"publishedSlug" VARCHAR NOT NULL,
|
||||
"updatedAt" BIGINT NOT NULL
|
||||
);
|
||||
@@ -0,0 +1,29 @@
|
||||
-- The home/private workspace (group id == user id) is now renameable like any
|
||||
-- other workspace. Previously its name was forced to mirror the user's profile
|
||||
-- name via triggers; that fought renames and meant the workspace had no name of
|
||||
-- its own. Stop syncing the name and default it to "My workspace".
|
||||
|
||||
-- 1. Stop re-syncing the home group name to the user's profile name. This trigger
|
||||
-- would clobber a user's custom workspace name whenever their profile changed.
|
||||
DROP TRIGGER IF EXISTS "update_home_group_name_trigger" ON public."user";
|
||||
DROP FUNCTION IF EXISTS update_home_group_name();
|
||||
|
||||
-- 2. Default new home groups to "My workspace" instead of the user's name. Keep the
|
||||
-- creation trigger (it also covers the legacy migrate-to-groups path) but have
|
||||
-- it set a fixed default rather than copying the user's name.
|
||||
CREATE OR REPLACE FUNCTION initialize_home_group_name() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
-- Only home groups have an id matching a user id.
|
||||
UPDATE "group"
|
||||
SET "name" = 'My workspace'
|
||||
FROM public."user" u
|
||||
WHERE u."id" = NEW."id" AND "group"."id" = NEW."id";
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- 3. Reset existing home group names to "My workspace". Before this migration,
|
||||
-- home group names were generated from profile names, so no existing name is
|
||||
-- an intentional workspace-name choice.
|
||||
UPDATE "group" SET "name" = 'My workspace' WHERE "id" IN (SELECT "id" FROM public."user");
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE public."group"
|
||||
ADD COLUMN "inviteLinkEnabled" BOOLEAN NOT NULL DEFAULT true;
|
||||
@@ -0,0 +1,65 @@
|
||||
-- Clean up orphan "link" group_file rows left by the removed drag-to-link feature.
|
||||
--
|
||||
-- #9107 shipped handleFileDragOperation with a "file link" concept: dragging a
|
||||
-- SHARED file you don't own onto a workspace inserted a group_file row into that
|
||||
-- workspace WITHOUT changing the file's owningGroupId (a "link", not a move). The
|
||||
-- validate_group_file_association trigger allowed it because the file is shared.
|
||||
-- #9254 removed that feature and moved to a single-ownership model, but never
|
||||
-- cleaned up the rows it had already written.
|
||||
--
|
||||
-- In the current model a non-home workspace lists a file only when it OWNS it
|
||||
-- (getWorkspaceFilesSorted: owningGroupId === workspaceId). So each leftover link
|
||||
-- row is invisible AND — worse — onEnterFile's old guard treated "a group_file row
|
||||
-- exists in one of my groups" as "the file is already visible to me", so it skipped
|
||||
-- creating the home guest-link. A member of the workspace the file was linked into
|
||||
-- therefore saw the file NOWHERE. (The onEnterFile guard is fixed separately to key
|
||||
-- off ownership; this migration removes the bad data.)
|
||||
--
|
||||
-- An orphan link row is a group_file row whose group neither owns the file nor is a
|
||||
-- user's home group (home group id === user id). We must NOT touch:
|
||||
-- * the owning row (groupId === owningGroupId), or
|
||||
-- * home guest rows (groupId is some user's id).
|
||||
|
||||
-- 1) Re-link affected files into the home of users who could reach them ONLY through
|
||||
-- an orphan row: members of the orphan's workspace who visited the file as a guest
|
||||
-- and have no home link yet. Mirrors the fixed onEnterFile so the file reappears in
|
||||
-- their sidebar without needing to re-open it. Runs before the delete so the orphan
|
||||
-- rows are still present to derive the affected set. Idempotent via ON CONFLICT.
|
||||
INSERT INTO public."group_file" ("fileId", "groupId", "createdAt", "updatedAt", "index")
|
||||
SELECT DISTINCT
|
||||
fs."fileId",
|
||||
fs."userId",
|
||||
(EXTRACT(EPOCH FROM NOW()) * 1000)::bigint,
|
||||
(EXTRACT(EPOCH FROM NOW()) * 1000)::bigint,
|
||||
NULL
|
||||
FROM public."group_file" orphan
|
||||
JOIN public."file" f ON f."id" = orphan."fileId"
|
||||
JOIN public."group_user" gu ON gu."groupId" = orphan."groupId"
|
||||
JOIN public."file_state" fs ON fs."fileId" = orphan."fileId" AND fs."userId" = gu."userId"
|
||||
WHERE
|
||||
-- orphan condition: the row's group does not own the file...
|
||||
orphan."groupId" IS DISTINCT FROM f."owningGroupId"
|
||||
-- ...and is not a user's home group
|
||||
AND NOT EXISTS (SELECT 1 FROM public."user" ou WHERE ou."id" = orphan."groupId")
|
||||
-- only surface files the user can actually still access
|
||||
AND f."shared" = true
|
||||
AND f."isDeleted" = false
|
||||
AND fs."isFileOwner" = false
|
||||
-- skip users who own the file via a workspace they belong to (already visible there)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public."group_user" owns
|
||||
WHERE owns."userId" = fs."userId" AND owns."groupId" = f."owningGroupId"
|
||||
)
|
||||
-- skip users who already have a home link for this file
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM public."group_file" home
|
||||
WHERE home."fileId" = fs."fileId" AND home."groupId" = fs."userId"
|
||||
)
|
||||
ON CONFLICT ("fileId", "groupId") DO NOTHING;
|
||||
|
||||
-- 2) Delete the orphan link rows.
|
||||
DELETE FROM public."group_file" gf
|
||||
USING public."file" f
|
||||
WHERE gf."fileId" = f."id"
|
||||
AND gf."groupId" IS DISTINCT FROM f."owningGroupId"
|
||||
AND NOT EXISTS (SELECT 1 FROM public."user" u WHERE u."id" = gf."groupId");
|
||||
@@ -0,0 +1,37 @@
|
||||
-- Tighten validate_group_file_association to match the single-ownership model.
|
||||
--
|
||||
-- The original trigger (023_groups.sql) allowed a group_file row whenever the file
|
||||
-- was shared OR owned by the group. That "shared" allowance is what let the removed
|
||||
-- drag-to-link feature (#9107, removed in #9254) link a shared file into ANY
|
||||
-- workspace without changing owningGroupId — the orphan rows cleaned up in migration
|
||||
-- 038. Now that only two kinds of group_file rows are legitimate, tighten the check
|
||||
-- so the database rejects a re-appearance of those cross-workspace links:
|
||||
--
|
||||
-- 1. The owning row: group_file.groupId === file.owningGroupId.
|
||||
-- 2. A home guest link: group_file.groupId is a user's home group (a user exists
|
||||
-- with that id) and the file is shared, i.e. the guest can actually access it.
|
||||
--
|
||||
-- A shared file can no longer be linked into a non-home workspace that doesn't own
|
||||
-- it. All current insert paths (createFile, onEnterFile home link, moveFileToWorkspace)
|
||||
-- satisfy one of the two allowed cases.
|
||||
CREATE OR REPLACE FUNCTION validate_group_file_association() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM "file" f
|
||||
WHERE f."id" = NEW."fileId"
|
||||
AND (
|
||||
-- the group owns the file
|
||||
f."owningGroupId" = NEW."groupId"
|
||||
-- ...or it's a home guest link for a shared file
|
||||
OR (
|
||||
f."shared" = true
|
||||
AND EXISTS (SELECT 1 FROM "user" u WHERE u."id" = NEW."groupId")
|
||||
)
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'Cannot link file to group: group must own the file, or the file must be shared and linked into a user home group';
|
||||
END IF;
|
||||
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
115
apps/dotcom/zero-cache/migrations/040_add_comments.sql
Normal file
115
apps/dotcom/zero-cache/migrations/040_add_comments.sql
Normal file
@@ -0,0 +1,115 @@
|
||||
-- Comments for app files, written by the file's Durable Object from the room's comment records
|
||||
-- (see TLComment / TLCommentThread in tlschema). Postgres is the sole durable store for comment
|
||||
-- records: the columns collectively carry every record field, so the Durable Object can rebuild
|
||||
-- its room's comment records from rows alone on cold start. `lastChangedClock` preserves each
|
||||
-- record's sync clock across reloads and guards upserts against no-op replays (an update that
|
||||
-- doesn't advance the clock is skipped, producing no WAL entry for Zero). `anchor`, `meta`,
|
||||
-- `resolvedBy`, `editedAt`, and `lastChangedClock` are persistence/rehydration columns not
|
||||
-- declared in the Zero schema, so they never reach clients.
|
||||
|
||||
CREATE TABLE comment_thread (
|
||||
"id" VARCHAR PRIMARY KEY,
|
||||
"fileId" VARCHAR NOT NULL,
|
||||
"pageId" VARCHAR NOT NULL,
|
||||
-- the thread's TLCommentAnchor (discriminated union) as JSON — canonical, used to rebuild the record
|
||||
"anchor" JSONB NOT NULL,
|
||||
-- denormalized from anchor for queries; only set for shape-anchored threads
|
||||
"shapeId" VARCHAR,
|
||||
"resolvedAt" BIGINT,
|
||||
"resolvedBy" VARCHAR,
|
||||
-- soft-deletion flag (see TLCommentThread.isDeleted): threads are never hard-deleted by
|
||||
-- clients, so the row (and its comments) stays for recovery while room loads and Zero
|
||||
-- queries filter it out
|
||||
"isDeleted" BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
-- no FK to "user": deleting a user must not cascade-delete threads (and thereby other
|
||||
-- authors' comments); the comment table's authorId FK handles per-author cleanup
|
||||
"createdBy" VARCHAR NOT NULL,
|
||||
"createdAt" BIGINT NOT NULL,
|
||||
"meta" JSONB NOT NULL,
|
||||
"lastChangedClock" BIGINT NOT NULL,
|
||||
CONSTRAINT comment_thread_file_id_fkey FOREIGN KEY ("fileId") REFERENCES public."file"("id") ON DELETE CASCADE,
|
||||
-- resolvedAt/resolvedBy encode one nullable `resolved` record field, so they must be set or
|
||||
-- null together; rehydration (rowToThreadRecord) relies on resolvedBy being non-null whenever
|
||||
-- resolvedAt is
|
||||
CONSTRAINT comment_thread_resolved_check CHECK (("resolvedAt" IS NULL) = ("resolvedBy" IS NULL))
|
||||
);
|
||||
|
||||
CREATE INDEX comment_thread_file_id_idx ON comment_thread("fileId");
|
||||
|
||||
CREATE TABLE comment (
|
||||
"id" VARCHAR PRIMARY KEY,
|
||||
"fileId" VARCHAR NOT NULL,
|
||||
"threadId" VARCHAR NOT NULL,
|
||||
"pageId" VARCHAR NOT NULL,
|
||||
"authorId" VARCHAR NOT NULL,
|
||||
-- author display fields, denormalized from the user table by the triggers below (same
|
||||
-- pattern as file."ownerName") — joining the user row would sync private fields to readers
|
||||
"authorName" VARCHAR DEFAULT '' NOT NULL,
|
||||
"authorColor" VARCHAR DEFAULT '' NOT NULL,
|
||||
"authorAvatar" VARCHAR DEFAULT '' NOT NULL,
|
||||
"body" JSONB NOT NULL,
|
||||
"createdAt" BIGINT NOT NULL,
|
||||
-- null until first edited (exact record field; updatedAt below is the derived sort key)
|
||||
"editedAt" BIGINT,
|
||||
-- soft-deletion flag (see TLComment.isDeleted): same model as the thread's — the row stays
|
||||
-- for recovery while room loads and Zero queries filter it out
|
||||
"isDeleted" BOOLEAN DEFAULT FALSE NOT NULL,
|
||||
"updatedAt" BIGINT NOT NULL,
|
||||
"meta" JSONB NOT NULL,
|
||||
"lastChangedClock" BIGINT NOT NULL,
|
||||
CONSTRAINT comment_file_id_fkey FOREIGN KEY ("fileId") REFERENCES public."file"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT comment_author_id_fkey FOREIGN KEY ("authorId") REFERENCES public."user"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT comment_thread_id_fkey FOREIGN KEY ("threadId") REFERENCES public."comment_thread"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX comment_file_id_idx ON comment("fileId");
|
||||
CREATE INDEX comment_thread_id_idx ON comment("threadId");
|
||||
CREATE INDEX comment_author_id_idx ON comment("authorId");
|
||||
|
||||
-- Stamp author details on insert — the Durable Object only knows authorId. BEFORE INSERT
|
||||
-- keeps it a single write with no extra WAL entry for Zero.
|
||||
CREATE OR REPLACE FUNCTION set_comment_author_details() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
author_name VARCHAR;
|
||||
author_color VARCHAR;
|
||||
author_avatar VARCHAR;
|
||||
BEGIN
|
||||
SELECT u."name", u."color", u."avatar" INTO author_name, author_color, author_avatar
|
||||
FROM public."user" u
|
||||
WHERE u."id" = NEW."authorId";
|
||||
-- a missing user row keeps the defaults, so the insert still fails its author FK check
|
||||
-- (see isCommentAuthorFkViolation) rather than erroring here
|
||||
IF FOUND THEN
|
||||
NEW."authorName" := author_name;
|
||||
NEW."authorColor" := author_color;
|
||||
NEW."authorAvatar" := author_avatar;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "set_comment_author_details_trigger"
|
||||
BEFORE INSERT OR UPDATE OF "authorId" ON comment
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_comment_author_details();
|
||||
|
||||
-- Propagate user renames, recolors, and avatar changes to their existing comments.
|
||||
CREATE OR REPLACE FUNCTION update_comment_author_details() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE comment
|
||||
SET "authorName" = NEW."name",
|
||||
"authorColor" = NEW."color",
|
||||
"authorAvatar" = NEW."avatar"
|
||||
WHERE "authorId" = NEW."id";
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "update_comment_author_details_trigger"
|
||||
AFTER UPDATE OF "name", "color", "avatar" ON public."user"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD."name" IS DISTINCT FROM NEW."name" OR OLD."color" IS DISTINCT FROM NEW."color" OR OLD."avatar" IS DISTINCT FROM NEW."avatar")
|
||||
EXECUTE FUNCTION update_comment_author_details();
|
||||
|
||||
ALTER PUBLICATION zero_data ADD TABLE public."comment_thread";
|
||||
ALTER PUBLICATION zero_data ADD TABLE public."comment";
|
||||
20
apps/dotcom/zero-cache/migrations/041_add_comment_read.sql
Normal file
20
apps/dotcom/zero-cache/migrations/041_add_comment_read.sql
Normal file
@@ -0,0 +1,20 @@
|
||||
-- Per-user comment read receipts for the app-level /comments view. Row present = the user has
|
||||
-- read the comment; marking unread deletes the row. `readAt` is stored so a future "edited
|
||||
-- comments become unread again" rule (readAt < updatedAt) is a client-only change. Authors'
|
||||
-- own comments get no row: the client treats authorId === me as implicitly read. Written via
|
||||
-- Zero custom mutators (comment.markRead / comment.markUnread), unlike comment/comment_thread
|
||||
-- which are written by the file's Durable Object.
|
||||
|
||||
CREATE TABLE comment_read (
|
||||
"userId" VARCHAR NOT NULL,
|
||||
"commentId" VARCHAR NOT NULL,
|
||||
"readAt" BIGINT NOT NULL,
|
||||
PRIMARY KEY ("userId", "commentId"),
|
||||
CONSTRAINT comment_read_user_id_fkey FOREIGN KEY ("userId") REFERENCES public."user"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT comment_read_comment_id_fkey FOREIGN KEY ("commentId") REFERENCES public."comment"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- covers the cascade path when comments are deleted
|
||||
CREATE INDEX comment_read_comment_id_idx ON comment_read("commentId");
|
||||
|
||||
ALTER PUBLICATION zero_data ADD TABLE public."comment_read";
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Per-comment @-mention rows, one per (comment, mentioned user), extracted from the comment's
|
||||
-- rich-text body by the file's Durable Object when it drains comment records to Postgres (see
|
||||
-- extractMentionIds / drainCommentOutbox in sync-worker). Mentions live inside the body JSON as
|
||||
-- TipTap mention nodes, which ZQL cannot reach, so this table is what lets the app-level
|
||||
-- notifications query filter "comments that mention me" server-side. Server-written only, like
|
||||
-- comment/comment_thread; rows are reconciled on every comment upsert and cascade away with
|
||||
-- their comment.
|
||||
|
||||
CREATE TABLE comment_mention (
|
||||
"commentId" VARCHAR NOT NULL,
|
||||
"userId" VARCHAR NOT NULL,
|
||||
PRIMARY KEY ("commentId", "userId"),
|
||||
CONSTRAINT comment_mention_comment_id_fkey FOREIGN KEY ("commentId") REFERENCES public."comment"("id") ON DELETE CASCADE,
|
||||
-- deleting a mentioned user only removes their mention rows, never the comment itself
|
||||
CONSTRAINT comment_mention_user_id_fkey FOREIGN KEY ("userId") REFERENCES public."user"("id") ON DELETE CASCADE
|
||||
);
|
||||
|
||||
-- covers the "comments that mention this user" lookup and the user-delete cascade path
|
||||
CREATE INDEX comment_mention_user_id_idx ON comment_mention("userId");
|
||||
|
||||
-- Backfill from existing comment bodies: `$.**` recursively finds mention nodes wherever they
|
||||
-- nest; DISTINCT collapses the multiple paths lax mode can reach a node through (and repeat
|
||||
-- mentions); the user join drops ids that aren't users (deleted accounts, malformed ids) —
|
||||
-- mirroring the FK-violation skip in the Durable Object's drain.
|
||||
INSERT INTO comment_mention ("commentId", "userId")
|
||||
SELECT DISTINCT c."id", u."id"
|
||||
FROM comment c
|
||||
CROSS JOIN LATERAL jsonb_path_query(c."body", 'lax $.** ? (@.type == "mention").attrs.id') AS mention(id)
|
||||
JOIN public."user" u ON u."id" = (mention.id #>> '{}');
|
||||
|
||||
ALTER PUBLICATION zero_data ADD TABLE public."comment_mention";
|
||||
@@ -0,0 +1,43 @@
|
||||
-- Emoji reactions on comments, written by the file's Durable Object from the room's
|
||||
-- comment-reaction records (see TLCommentReaction in tlschema). Same contract as comment and
|
||||
-- comment_thread: Postgres is the sole durable store, the columns carry every record field so the
|
||||
-- Durable Object can rebuild the records from rows alone on cold start, and `lastChangedClock`
|
||||
-- preserves each record's sync clock and guards upserts against no-op replays.
|
||||
--
|
||||
-- A reaction is its own row rather than a column on comment because it is one user's data about
|
||||
-- someone else's comment — the same reason comment_read is its own table. It keeps every write
|
||||
-- scoped to one user's own record, so concurrent reactions from different people can't overwrite
|
||||
-- each other, and a user can never write a reaction attributed to anyone else.
|
||||
--
|
||||
-- `meta` and `lastChangedClock` are persistence/rehydration columns not declared in the Zero
|
||||
-- schema, so they never reach clients.
|
||||
|
||||
CREATE TABLE comment_reaction (
|
||||
"id" VARCHAR PRIMARY KEY,
|
||||
"fileId" VARCHAR NOT NULL,
|
||||
"commentId" VARCHAR NOT NULL,
|
||||
"threadId" VARCHAR NOT NULL,
|
||||
"pageId" VARCHAR NOT NULL,
|
||||
"userId" VARCHAR NOT NULL,
|
||||
"emoji" VARCHAR NOT NULL,
|
||||
"createdAt" BIGINT NOT NULL,
|
||||
"meta" JSONB NOT NULL,
|
||||
"lastChangedClock" BIGINT NOT NULL,
|
||||
CONSTRAINT comment_reaction_file_id_fkey FOREIGN KEY ("fileId") REFERENCES public."file"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT comment_reaction_comment_id_fkey FOREIGN KEY ("commentId") REFERENCES public."comment"("id") ON DELETE CASCADE,
|
||||
CONSTRAINT comment_reaction_thread_id_fkey FOREIGN KEY ("threadId") REFERENCES public."comment_thread"("id") ON DELETE CASCADE,
|
||||
-- deleting a user removes their reactions, never the comment they reacted to
|
||||
CONSTRAINT comment_reaction_user_id_fkey FOREIGN KEY ("userId") REFERENCES public."user"("id") ON DELETE CASCADE,
|
||||
-- one record per (comment, user, emoji): a user may hold multiple reactions on a comment (one
|
||||
-- per emoji), but not the same emoji twice. The record id is derived from the same triple
|
||||
-- (createCommentReactionId), so this backstops the invariant at the database.
|
||||
CONSTRAINT comment_reaction_comment_user_emoji_unique UNIQUE ("commentId", "userId", "emoji")
|
||||
);
|
||||
|
||||
CREATE INDEX comment_reaction_file_id_idx ON comment_reaction("fileId");
|
||||
CREATE INDEX comment_reaction_comment_id_idx ON comment_reaction("commentId");
|
||||
CREATE INDEX comment_reaction_thread_id_idx ON comment_reaction("threadId");
|
||||
-- covers the user-delete cascade path
|
||||
CREATE INDEX comment_reaction_user_id_idx ON comment_reaction("userId");
|
||||
|
||||
ALTER PUBLICATION zero_data ADD TABLE public."comment_reaction";
|
||||
97
apps/dotcom/zero-cache/migrations/044_file_visitor.sql
Normal file
97
apps/dotcom/zero-cache/migrations/044_file_visitor.sql
Normal file
@@ -0,0 +1,97 @@
|
||||
-- A shareable, per-file record of who has opened a board, so the comment composer can offer past
|
||||
-- viewers (not just workspace members) as @-mention targets. This is its OWN table rather than
|
||||
-- columns on file_state: file_state also carries private per-user data — lastSessionState (a user's
|
||||
-- camera position, current page, and selected shapes) and visit/edit timestamps — that must never
|
||||
-- sync to other users. Zero synced queries replicate whole rows, so exposing the viewer list from
|
||||
-- file_state would leak all of that. Every column here is safe to show to any file collaborator.
|
||||
-- Identity is denormalized (same reasoning as comment.authorName, migration 040): joining the user
|
||||
-- row would sync private user fields to every reader.
|
||||
|
||||
CREATE TABLE file_visitor (
|
||||
"userId" VARCHAR NOT NULL,
|
||||
"fileId" VARCHAR NOT NULL,
|
||||
"userName" VARCHAR DEFAULT '' NOT NULL,
|
||||
"userColor" VARCHAR DEFAULT '' NOT NULL,
|
||||
-- mirrored from file_state for most-recent-first ordering of the roster. NOT NULL (falling back
|
||||
-- to lastEditAt/firstVisitAt/now at write time) so the fileVisitors query's ORDER BY ... DESC is
|
||||
-- deterministic — ZQL has no NULLS LAST, and Postgres DESC would sort nulls first.
|
||||
"lastVisitAt" BIGINT NOT NULL,
|
||||
PRIMARY KEY ("userId", "fileId")
|
||||
);
|
||||
|
||||
CREATE INDEX file_visitor_file_id_idx ON file_visitor("fileId");
|
||||
|
||||
-- Backfill from existing visits. user.name/user.color are NOT NULL (000_seed.sql), so COALESCE only
|
||||
-- guards the (impossible here) missing-join case and keeps the NOT NULL columns satisfied.
|
||||
INSERT INTO file_visitor ("userId", "fileId", "userName", "userColor", "lastVisitAt")
|
||||
SELECT fs."userId", fs."fileId", COALESCE(u."name", ''), COALESCE(u."color", ''),
|
||||
-- same fallback chain as the client's getFileVisitDate, then "now" for rows with no visit data
|
||||
COALESCE(fs."lastVisitAt", fs."lastEditAt", fs."firstVisitAt", (EXTRACT(EPOCH FROM now()) * 1000)::BIGINT)
|
||||
FROM file_state fs
|
||||
JOIN public."user" u ON u."id" = fs."userId"
|
||||
ON CONFLICT ("userId", "fileId") DO NOTHING;
|
||||
|
||||
-- Mirror a visit (file_state insert, or a lastVisitAt refresh on exit) into file_visitor, stamping
|
||||
-- the visitor's current name/color. Keyed the same as file_state, so it's a natural upsert.
|
||||
CREATE OR REPLACE FUNCTION sync_file_visitor_on_file_state() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
visitor_name VARCHAR;
|
||||
visitor_color VARCHAR;
|
||||
BEGIN
|
||||
SELECT u."name", u."color" INTO visitor_name, visitor_color
|
||||
FROM public."user" u
|
||||
WHERE u."id" = NEW."userId";
|
||||
INSERT INTO file_visitor ("userId", "fileId", "userName", "userColor", "lastVisitAt")
|
||||
VALUES (NEW."userId", NEW."fileId", COALESCE(visitor_name, ''), COALESCE(visitor_color, ''),
|
||||
-- a fresh file_state row (createFile / first onEnterFile) has no lastVisitAt yet; the visit is
|
||||
-- happening right now, so stamp "now" rather than leave the roster entry unsortable
|
||||
COALESCE(NEW."lastVisitAt", NEW."lastEditAt", NEW."firstVisitAt", (EXTRACT(EPOCH FROM now()) * 1000)::BIGINT))
|
||||
ON CONFLICT ("userId", "fileId") DO UPDATE SET
|
||||
"userName" = COALESCE(visitor_name, file_visitor."userName"),
|
||||
"userColor" = COALESCE(visitor_color, file_visitor."userColor"),
|
||||
"lastVisitAt" = COALESCE(NEW."lastVisitAt", file_visitor."lastVisitAt");
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "sync_file_visitor_on_file_state_trigger"
|
||||
AFTER INSERT OR UPDATE OF "lastVisitAt" ON file_state
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION sync_file_visitor_on_file_state();
|
||||
|
||||
-- Drop the roster entry when the visit is removed (e.g. un-sharing a file deletes non-owner
|
||||
-- file_state rows — see 034_fix_unshare_group_file_cleanup.sql), so a viewer who loses access also
|
||||
-- drops out of the mention roster.
|
||||
CREATE OR REPLACE FUNCTION delete_file_visitor_on_file_state() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
DELETE FROM file_visitor WHERE "userId" = OLD."userId" AND "fileId" = OLD."fileId";
|
||||
RETURN OLD;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "delete_file_visitor_on_file_state_trigger"
|
||||
AFTER DELETE ON file_state
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION delete_file_visitor_on_file_state();
|
||||
|
||||
-- Propagate user renames and recolors to their existing roster entries, so the roster stays fresh
|
||||
-- even for a viewer who never re-opens the board (same pattern as update_comment_author_details).
|
||||
CREATE OR REPLACE FUNCTION update_file_visitor_user_details() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE file_visitor
|
||||
SET "userName" = NEW."name",
|
||||
"userColor" = NEW."color"
|
||||
WHERE "userId" = NEW."id";
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "update_file_visitor_user_details_trigger"
|
||||
AFTER UPDATE OF "name", "color" ON public."user"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD."name" IS DISTINCT FROM NEW."name" OR OLD."color" IS DISTINCT FROM NEW."color")
|
||||
EXECUTE FUNCTION update_file_visitor_user_details();
|
||||
|
||||
-- New table, so it must be added to the Zero replication publication (unlike file_state, which was
|
||||
-- already a member).
|
||||
ALTER PUBLICATION zero_data ADD TABLE file_visitor;
|
||||
@@ -0,0 +1,52 @@
|
||||
-- Denormalize the reacting user's display name onto reaction rows, like comment.authorName:
|
||||
-- joining "user" from the notifications query would expose private user fields to file readers.
|
||||
|
||||
ALTER TABLE comment_reaction ADD COLUMN "userName" VARCHAR DEFAULT '' NOT NULL;
|
||||
|
||||
-- Stamp the name on insert — the Durable Object only knows userId.
|
||||
CREATE OR REPLACE FUNCTION set_comment_reaction_user_name() RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
user_name VARCHAR;
|
||||
BEGIN
|
||||
SELECT u."name" INTO user_name
|
||||
FROM public."user" u
|
||||
WHERE u."id" = NEW."userId";
|
||||
-- a missing user row keeps the default, so the insert still fails its user FK check
|
||||
IF FOUND THEN
|
||||
NEW."userName" := user_name;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "set_comment_reaction_user_name_trigger"
|
||||
BEFORE INSERT OR UPDATE OF "userId" ON comment_reaction
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_comment_reaction_user_name();
|
||||
|
||||
-- Propagate user renames to their existing reactions.
|
||||
CREATE OR REPLACE FUNCTION update_comment_reaction_user_name() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
UPDATE comment_reaction
|
||||
SET "userName" = NEW."name"
|
||||
WHERE "userId" = NEW."id";
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "update_comment_reaction_user_name_trigger"
|
||||
AFTER UPDATE OF "name" ON public."user"
|
||||
FOR EACH ROW
|
||||
WHEN (OLD."name" IS DISTINCT FROM NEW."name")
|
||||
EXECUTE FUNCTION update_comment_reaction_user_name();
|
||||
|
||||
-- Backfill rows that predate the column.
|
||||
UPDATE comment_reaction cr
|
||||
SET "userName" = u."name"
|
||||
FROM public."user" u
|
||||
WHERE u."id" = cr."userId";
|
||||
|
||||
-- Top-N scans for the app-level feeds: the comments and reactions queries order by createdAt
|
||||
-- desc with a limit; without these, every query hydration scans and sorts the whole table.
|
||||
CREATE INDEX comment_created_at_idx ON comment("createdAt" DESC);
|
||||
CREATE INDEX comment_reaction_created_at_idx ON comment_reaction("createdAt" DESC);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- Stamp comment."createdAt" on the server: the notifications reply gate compares comment times
|
||||
-- with a strict ">", which needs one clock and one total order per thread. Monotonic (max + 1)
|
||||
-- rather than plain time because the Durable Object drains several comments in one transaction,
|
||||
-- where now() is frozen and clock_timestamp() ties at millisecond resolution. No race on the
|
||||
-- SELECT MAX: a thread belongs to one file, and one file is one Durable Object.
|
||||
--
|
||||
-- BEFORE INSERT only: the drain retries at-least-once via ON CONFLICT, whose update set does not
|
||||
-- include "createdAt", so the first successful insert's stamp is permanent.
|
||||
CREATE OR REPLACE FUNCTION set_comment_created_at() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW."createdAt" := GREATEST(
|
||||
(SELECT COALESCE(MAX("createdAt"), 0) + 1 FROM comment WHERE "threadId" = NEW."threadId"),
|
||||
(EXTRACT(EPOCH FROM clock_timestamp()) * 1000)::BIGINT
|
||||
);
|
||||
-- keep the derived sort key consistent with the re-stamped creation time
|
||||
NEW."updatedAt" := GREATEST(NEW."updatedAt", NEW."createdAt");
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER "set_comment_created_at_trigger"
|
||||
BEFORE INSERT ON comment
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_comment_created_at();
|
||||
53
apps/dotcom/zero-cache/migrations/047_effect_outbox.sql
Normal file
53
apps/dotcom/zero-cache/migrations/047_effect_outbox.sql
Normal file
@@ -0,0 +1,53 @@
|
||||
-- Generic transactional outbox for server-side effects. Rows are written by
|
||||
-- row-level triggers on source tables (only "file" today) so every write path is
|
||||
-- captured, including the cleanup_deleted_group_trigger cascade. Drained by
|
||||
-- TLFileEffectProcessor in sync-worker, which dispatches on "tableName".
|
||||
-- Future effect sources (e.g. notifications) add a trigger + a consumer handler.
|
||||
-- NOT added to the zero_data publication: server-only table.
|
||||
|
||||
CREATE TABLE public.effect_outbox (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
"tableName" VARCHAR NOT NULL, -- source table
|
||||
"entityId" VARCHAR NOT NULL, -- source row id
|
||||
command VARCHAR NOT NULL, -- 'insert' | 'update' | 'delete'
|
||||
payload JSONB NOT NULL, -- NEW row (OLD for delete)
|
||||
"prevPayload" JSONB, -- full OLD row, updates only
|
||||
attempts INT NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
|
||||
CREATE OR REPLACE FUNCTION file_effect_outbox_fn() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
INSERT INTO public.effect_outbox ("tableName", "entityId", command, payload)
|
||||
VALUES ('file', NEW.id, 'insert', to_jsonb(NEW));
|
||||
RETURN NEW;
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
-- Skip no-op updates: room persists bump "updatedAt" every few seconds per active
|
||||
-- room; only effect-relevant columns should produce outbox rows.
|
||||
IF NEW.name IS NOT DISTINCT FROM OLD.name
|
||||
AND NEW.shared IS NOT DISTINCT FROM OLD.shared
|
||||
AND NEW."sharedLinkType" IS NOT DISTINCT FROM OLD."sharedLinkType"
|
||||
AND NEW.published IS NOT DISTINCT FROM OLD.published
|
||||
AND NEW."lastPublished" IS NOT DISTINCT FROM OLD."lastPublished"
|
||||
AND NEW."publishedSlug" IS NOT DISTINCT FROM OLD."publishedSlug"
|
||||
AND NEW."isDeleted" IS NOT DISTINCT FROM OLD."isDeleted"
|
||||
AND NEW."ownerId" IS NOT DISTINCT FROM OLD."ownerId"
|
||||
AND NEW."owningGroupId" IS NOT DISTINCT FROM OLD."owningGroupId"
|
||||
THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
INSERT INTO public.effect_outbox ("tableName", "entityId", command, payload, "prevPayload")
|
||||
VALUES ('file', NEW.id, 'update', to_jsonb(NEW), to_jsonb(OLD));
|
||||
RETURN NEW;
|
||||
ELSE
|
||||
INSERT INTO public.effect_outbox ("tableName", "entityId", command, payload)
|
||||
VALUES ('file', OLD.id, 'delete', to_jsonb(OLD));
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER file_effect_outbox_after_change
|
||||
AFTER INSERT OR UPDATE OR DELETE ON public.file
|
||||
FOR EACH ROW EXECUTE FUNCTION file_effect_outbox_fn();
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Timed retry backoff for effect_outbox. A failed row schedules its next
|
||||
-- eligible retry so rapid pokes can't burn all attempts in a burst; the drain
|
||||
-- filter skips rows until "nextRetryAt" passes and the 30s alarm sweep retries.
|
||||
ALTER TABLE public.effect_outbox ADD COLUMN "nextRetryAt" TIMESTAMPTZ;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Leftovers of the legacy custom sync engine, removed in #9894. The tables had no
|
||||
-- readers or writers since then and were never in the zero_data publication.
|
||||
-- "alltables" was the legacy replicator's FOR ALL TABLES publication; with no
|
||||
-- subscriber it retains no WAL, but it makes UPDATE/DELETE fail on any future
|
||||
-- table without a replica identity, so it goes too. Zero only uses zero_data.
|
||||
-- The tlpr_ replication slot is dropped manually per environment (see #9899).
|
||||
|
||||
DROP PUBLICATION IF EXISTS alltables;
|
||||
DROP TABLE IF EXISTS public.user_mutation_number;
|
||||
DROP TABLE IF EXISTS public.replicator_boot_id;
|
||||
DROP TABLE IF EXISTS public.user_boot_id;
|
||||
@@ -0,0 +1,179 @@
|
||||
-- Drops the legacy user-owns-file model (issue #10050).
|
||||
--
|
||||
-- Every file has lived in a workspace since the groups migration, `file."ownerId"` has been
|
||||
-- NULL throughout production since then, and #10391 removed the last code that read it. What
|
||||
-- remains is the schema itself plus the trigger functions that still maintain it.
|
||||
--
|
||||
-- Postgres tracks dependencies for indexes, foreign keys, check constraints and a trigger's
|
||||
-- `UPDATE OF` column list, so those drop or block on their own. It does NOT parse plpgsql
|
||||
-- bodies: a function still naming a dropped column survives the DDL and fails at runtime on
|
||||
-- the next write. That is why the function work below is explicit, and comes before the
|
||||
-- column drops.
|
||||
--
|
||||
-- The runner puts the whole migration in one transaction (migrate.ts). A transaction with any
|
||||
-- DDL on a replicated table resets every view-syncer pipeline and rehydrates connected clients,
|
||||
-- once per transaction rather than per statement, and Zero is notified once after all of it.
|
||||
-- Deploy off-peak, and only after #10653 has shipped: a client whose schema still declares
|
||||
-- these columns is disconnected the moment they drop.
|
||||
|
||||
-- Fails before any lock is taken if a file still has no workspace, with a message that says
|
||||
-- so. Without this the SET NOT NULL below fails with a bare "contains null values".
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (SELECT 1 FROM public."file" WHERE "owningGroupId" IS NULL) THEN
|
||||
RAISE EXCEPTION 'files without an owningGroupId remain; every file must belong to a workspace before the legacy owner columns can be dropped';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Lock order. An app transaction that changes a user's name or avatar holds `user` and then
|
||||
-- writes `file` through update_file_owner_details_trigger, which is installed until this
|
||||
-- migration drops it, and every `file_state` writer reads `file` first. The DDL below takes
|
||||
-- ACCESS EXCLUSIVE on all three, and locks are held to the end of the transaction, so taking
|
||||
-- them in any other order than `user`, `file`, `file_state` can deadlock with a mutator that
|
||||
-- is mid-flight when the deploy runs. Statement order is not enough to guarantee that, so the
|
||||
-- first two are taken here explicitly. `file_state` is not: nothing needs it until after the
|
||||
-- scan below, and it stays writable until then.
|
||||
LOCK TABLE public."user", public."file" IN ACCESS EXCLUSIVE MODE;
|
||||
|
||||
-- The one statement here that does real work: a full scan of `file`. It comes first so that
|
||||
-- `user` is held for the scan plus catalog writes rather than for the scan plus everything
|
||||
-- else, and `file_state` is not held for the scan at all. With the legacy model gone, every
|
||||
-- file belongs to a workspace.
|
||||
ALTER TABLE public."file" ALTER COLUMN "owningGroupId" SET NOT NULL;
|
||||
|
||||
-- These exist only to maintain the legacy columns.
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_update_file_state_on_file_change ON public."file";
|
||||
DROP FUNCTION IF EXISTS public.update_file_state_on_file_change();
|
||||
|
||||
DROP TRIGGER IF EXISTS trigger_update_is_file_owner ON public."file_state";
|
||||
DROP FUNCTION IF EXISTS public.update_is_file_owner();
|
||||
|
||||
DROP TRIGGER IF EXISTS update_file_owner_details_trigger ON public."user";
|
||||
DROP FUNCTION IF EXISTS public.update_file_owner_details();
|
||||
|
||||
-- The runtime backfill that moved users onto the groups model, and the source of the
|
||||
-- `owningGroupId`-without-`group_file` orphans repaired ahead of this migration: it set
|
||||
-- `owningGroupId` for every file a user owned but created links only for files the user had a
|
||||
-- `file_state` row for, so a file its owner had never opened got the id and no link.
|
||||
DROP FUNCTION IF EXISTS public.migrate_user_to_groups(text, text);
|
||||
|
||||
-- Was two arms, one per ownership model. Only the group arm is reachable now, its
|
||||
-- `"ownerAvatar" = ''` assignment goes with the column, and its null check goes with the
|
||||
-- NOT NULL constraint above.
|
||||
CREATE OR REPLACE FUNCTION public.set_file_owner_details()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
BEGIN
|
||||
UPDATE "file"
|
||||
SET "ownerName" = g."name"
|
||||
FROM public."group" g
|
||||
WHERE g."id" = NEW."owningGroupId" AND "file"."id" = NEW."id";
|
||||
RETURN NEW;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- Redefined rather than left alone: its column list still named "ownerId".
|
||||
CREATE OR REPLACE TRIGGER set_file_owner_details_trigger
|
||||
AFTER INSERT OR UPDATE OF "owningGroupId" ON public."file"
|
||||
FOR EACH ROW EXECUTE FUNCTION set_file_owner_details();
|
||||
|
||||
CREATE OR REPLACE FUNCTION public.update_file_group_details()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
BEGIN
|
||||
UPDATE "file"
|
||||
SET "ownerName" = NEW.name
|
||||
WHERE "owningGroupId" = NEW.id;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- Both dropped guards were already no-ops: `ownerId` is NULL on every row, and
|
||||
-- `NULL IS DISTINCT FROM x` is TRUE, so neither ever excluded anything. The `group_user`
|
||||
-- membership checks are what actually protect an owner's own state on unshare.
|
||||
CREATE OR REPLACE FUNCTION public.delete_file_states()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
BEGIN
|
||||
IF OLD.shared = TRUE AND NEW.shared = FALSE THEN
|
||||
DELETE FROM file_state fs
|
||||
WHERE fs."fileId" = OLD.id
|
||||
-- not a current member of the owning group
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM group_user gu
|
||||
WHERE gu."groupId" = OLD."owningGroupId"
|
||||
AND gu."userId" = fs."userId"
|
||||
);
|
||||
|
||||
DELETE FROM group_file gf
|
||||
WHERE gf."fileId" = OLD.id
|
||||
-- never the owning group's own row: that's where the file lives
|
||||
AND gf."groupId" <> OLD."owningGroupId"
|
||||
-- not a home-group link of a current owning-group member
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM group_user gu
|
||||
WHERE gu."groupId" = OLD."owningGroupId"
|
||||
AND gu."userId" = gf."groupId"
|
||||
);
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- Only the no-op short-circuit changes: "ownerId" leaves the list of columns whose change is
|
||||
-- worth an outbox row. Miss this and every file write starts producing effects again.
|
||||
CREATE OR REPLACE FUNCTION public.file_effect_outbox_fn()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
BEGIN
|
||||
IF TG_OP = 'INSERT' THEN
|
||||
INSERT INTO public.effect_outbox ("tableName", "entityId", command, payload)
|
||||
VALUES ('file', NEW.id, 'insert', to_jsonb(NEW));
|
||||
RETURN NEW;
|
||||
ELSIF TG_OP = 'UPDATE' THEN
|
||||
-- Skip no-op updates: room persists bump "updatedAt" every few seconds per active
|
||||
-- room; only effect-relevant columns should produce outbox rows.
|
||||
IF NEW.name IS NOT DISTINCT FROM OLD.name
|
||||
AND NEW.shared IS NOT DISTINCT FROM OLD.shared
|
||||
AND NEW."sharedLinkType" IS NOT DISTINCT FROM OLD."sharedLinkType"
|
||||
AND NEW.published IS NOT DISTINCT FROM OLD.published
|
||||
AND NEW."lastPublished" IS NOT DISTINCT FROM OLD."lastPublished"
|
||||
AND NEW."publishedSlug" IS NOT DISTINCT FROM OLD."publishedSlug"
|
||||
AND NEW."isDeleted" IS NOT DISTINCT FROM OLD."isDeleted"
|
||||
AND NEW."owningGroupId" IS NOT DISTINCT FROM OLD."owningGroupId"
|
||||
THEN
|
||||
RETURN NEW;
|
||||
END IF;
|
||||
INSERT INTO public.effect_outbox ("tableName", "entityId", command, payload, "prevPayload")
|
||||
VALUES ('file', NEW.id, 'update', to_jsonb(NEW), to_jsonb(OLD));
|
||||
RETURN NEW;
|
||||
ELSE
|
||||
INSERT INTO public.effect_outbox ("tableName", "entityId", command, payload)
|
||||
VALUES ('file', OLD.id, 'delete', to_jsonb(OLD));
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
END;
|
||||
$function$;
|
||||
|
||||
-- Postgres would drop all three with the column; spelled out so reviewing this does not
|
||||
-- require knowing that.
|
||||
|
||||
DROP INDEX IF EXISTS public.file_owner_index;
|
||||
ALTER TABLE public."file" DROP CONSTRAINT IF EXISTS "file_owner_xor_check";
|
||||
ALTER TABLE public."file" DROP CONSTRAINT IF EXISTS "file_ownerId_fkey";
|
||||
|
||||
-- `file_state."isPinned"` needs no backfill: the client has read pin state from
|
||||
-- `group_file."index"` since groups landed, so the ~331 rows still saying `true` have had no
|
||||
-- effect for months. Copying them into `index` would re-pin boards that are currently
|
||||
-- unpinned, and `unpinFile` writes the same `index = NULL` those rows would be matched by, so
|
||||
-- a deliberate unpin is indistinguishable from a never-migrated pin.
|
||||
|
||||
ALTER TABLE public."file" DROP COLUMN IF EXISTS "ownerId";
|
||||
ALTER TABLE public."file" DROP COLUMN IF EXISTS "ownerAvatar";
|
||||
ALTER TABLE public."file_state" DROP COLUMN IF EXISTS "isFileOwner";
|
||||
ALTER TABLE public."file_state" DROP COLUMN IF EXISTS "isPinned";
|
||||
@@ -0,0 +1,4 @@
|
||||
-- Last failure message for an effect_outbox row, overwritten on every failed attempt.
|
||||
-- The drain only reports the first and parking attempts to Sentry, so without this a
|
||||
-- parked row shows "attempts 10" and nothing else; the row carries its own diagnosis.
|
||||
ALTER TABLE public.effect_outbox ADD COLUMN "lastError" TEXT;
|
||||
31
apps/dotcom/zero-cache/migrations/052_file_search_index.sql
Normal file
31
apps/dotcom/zero-cache/migrations/052_file_search_index.sql
Normal file
@@ -0,0 +1,31 @@
|
||||
-- Serves `search_boards` (searchBoards.ts): each arm of its per-workspace merge filters on
|
||||
-- `"owningGroupId" = :group` and orders by "createdAt" desc, so with both in one index a page is
|
||||
-- read in index order and stops, instead of top-N sorting the caller's whole in-scope set. Only
|
||||
-- usable now that the access predicate is a single equality — the `"ownerId" = :caller` arm it used
|
||||
-- to carry made the planner answer with a BitmapOr, whose output has no order.
|
||||
--
|
||||
-- The `file.id` tiebreak in that ORDER BY is deliberately left out: the primary key already makes
|
||||
-- id unique, and ties on "createdAt" are rare enough that sorting within one is not worth a wider
|
||||
-- index.
|
||||
--
|
||||
-- Built plainly rather than CONCURRENTLY, which would need the runner to leave its transaction. A
|
||||
-- plain build takes SHARE on "file", so reads continue but every write blocks — board create,
|
||||
-- rename, delete, and the "updatedAt" bump `005_update_file_trigger.sql` fires on any row change.
|
||||
-- Measured at production's shape (968k rows, 406MB heap, Postgres 16) that window is 1-2 seconds.
|
||||
--
|
||||
-- The runner holds one transaction over every migration in a run, so the SHARE lock is taken here
|
||||
-- and released at the run's COMMIT, not when the build ends: everything queued after this file
|
||||
-- extends the window in which writes to "file" block.
|
||||
--
|
||||
-- Keep it the only index build in its run, and specifically do not add one on "group_file" here.
|
||||
-- This transaction would then hold "file" while waiting for "group_file", and `moveFileToWorkspace`
|
||||
-- takes those two in the opposite order — reproduced on PG16, and the migration is the one Postgres
|
||||
-- kills, so the deploy fails. A run that locks one of the two cannot be in that cycle at all.
|
||||
|
||||
-- IF NOT EXISTS so a deploy still succeeds when the index was created by hand ahead of it — the
|
||||
-- likely reason being someone building it out of band to avoid the write stall above. Note it
|
||||
-- matches on *name only*, not definition: an index of this name with different columns or ordering
|
||||
-- would satisfy this and leave the search unindexed with a green deploy. Check `pg_index` rather
|
||||
-- than trusting the clause if you ever pre-create it.
|
||||
CREATE INDEX IF NOT EXISTS "file_owning_group_created_at_idx"
|
||||
ON public."file" ("owningGroupId", "createdAt" DESC);
|
||||
Reference in New Issue
Block a user