Files
upstream-mirror a774ac673a
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
snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
2026-09-23 16:12:52 +05:00

452 lines
16 KiB
PL/PgSQL

----------------------------------
--- 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;