snapshot of https://github.com/tldraw/tldraw.git (main, 2026-09-23; blobs > 131072B omitted)
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
Some checks failed
Deploy analytics worker / Deploy analytics worker to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
i18n upload strings / Upload latest strings (push) Failing after 1s
Deploy bemo / Deploy bemo to ${{ (github.ref == 'refs/heads/bemo-production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Failing after 1s
Deploy grafana resources / ${{ github.event_name == 'push' && 'Deploy grafana resources' || 'Validate grafana resources (dry run)' }} (push) Failing after 0s
End to end tests (dotcom) / Check for relevant changes (push) Failing after 0s
End to end tests (examples) / Check for relevant changes (push) Failing after 0s
End to end tests (mermaid readability) / Check for relevant changes (push) Failing after 1s
End to end tests (perf) / Check for relevant changes (push) Failing after 0s
Publish editor extensions / Publish editor extensions (push) Failing after 1s
Publish SDK packages / Remove the publish-packages label (push) Has been skipped
Checks / Tests & checks (push) Has been cancelled
Checks / Build all projects (push) Has been cancelled
Deploy templates / Deploy templates (push) Has been cancelled
End to end tests (dotcom) / End to end tests (dotcom) (push) Has been cancelled
End to end tests (mermaid readability) / End to end tests (mermaid readability) (push) Has been cancelled
End to end tests (perf) / End to end tests (perf) (push) Has been cancelled
Deploy .com / Deploy dotcom to ${{ (github.ref == 'refs/heads/production' && 'production') || (github.ref == 'refs/heads/main' && 'staging') || 'preview' }} (push) Has been cancelled
End to end tests (examples) / End to end tests (examples) (push) Has been cancelled
Publish SDK packages / Publish SDK packages (push) Has been cancelled
Publish SDK packages / Tell user released version (push) Has been cancelled
Publish SDK packages / Publishes code templates to separate repositories (push) Has been cancelled
Publish SDK packages / Deploys the template demos with the new SDK version (push) Has been cancelled
This commit is contained in:
@@ -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";
|
||||
Reference in New Issue
Block a user