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
180 lines
8.0 KiB
PL/PgSQL
180 lines
8.0 KiB
PL/PgSQL
-- 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";
|