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

This commit is contained in:
upstream-mirror
2026-09-23 16:12:52 +05:00
commit a774ac673a
4541 changed files with 770499 additions and 0 deletions

View 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();

View File

@@ -0,0 +1,4 @@
CREATE TABLE "replicator_boot_id" (
"replicatorId" VARCHAR NOT NULL PRIMARY KEY,
"bootId" VARCHAR NOT NULL
);

View 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");

View File

@@ -0,0 +1,4 @@
-- Purpose: Make the publishedSlug column unique in the file table.
ALTER TABLE public.file
ADD CONSTRAINT unique_publishedSlug UNIQUE ("publishedSlug");

View File

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

View File

@@ -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();

View File

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

View File

@@ -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();

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

View File

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

View 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")
);

View File

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

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

View File

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

View File

@@ -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");

View File

@@ -0,0 +1 @@
ALTER TABLE file REPLICA IDENTITY FULL;

View File

@@ -0,0 +1 @@
CREATE PUBLICATION zero_data FOR TABLE file, file_state, public.user;

View File

@@ -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");

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,4 @@
-- Purpose: Renames showUiLabels column to enhancedA11yMode column in the user table
ALTER TABLE "user"
RENAME COLUMN "showUiLabels" TO "enhancedA11yMode";

View File

@@ -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'));

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

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

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

View File

@@ -0,0 +1,2 @@
ALTER TABLE user_fairies
ADD COLUMN "weeklyUsage" JSONB DEFAULT '{}'::jsonb;

View 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");

View File

@@ -0,0 +1,3 @@
ALTER TABLE fairy_invite
ADD COLUMN description TEXT,
ADD COLUMN "redeemedBy" JSONB DEFAULT '[]'::jsonb;

View File

@@ -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");

View File

@@ -0,0 +1,2 @@
ALTER TABLE user_fairies
ADD COLUMN "weeklyLimit" INTEGER DEFAULT 25;

View File

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

View File

@@ -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");

View File

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

View File

@@ -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'));

View File

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

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

View File

@@ -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");

View File

@@ -0,0 +1,2 @@
ALTER TABLE public."group"
ADD COLUMN "inviteLinkEnabled" BOOLEAN NOT NULL DEFAULT true;

View File

@@ -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");

View File

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

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

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

View File

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

View File

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

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

View File

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

View File

@@ -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();

View 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();

View File

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

View File

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

View File

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

View File

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

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