mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat(core): make one address one account, on rows that never had one
Runs against a database where every user was created by a gaming sign-in, so most rows have no email at all and nothing has ever stopped two rows from sharing one. The address is normalized first, duplicates are separated before the unique index exists — the older row keeps the address, the newer one is asked for a new one and loses nothing else — and the index is partial so that accounts with no address do not collide with each other. Verified against a database built to contain the awkward rows rather than against an empty schema, by the script alongside it: an account with no address, one with both, one with two connections, a duplicated address in two different cases, an account already over the connection cap, and a deleted row holding an address a live row also holds. Removing the de-duplication makes the index creation fail, which is how we know the fixtures are load-bearing. Also adds a nullable column recording which attempt holds a session run. It is not part of the change above and carries no reason of its own; the endpoint that reads and writes it arrives separately, and it is here because a schema change has one owner at a time.
This commit is contained in:
66
packages/core/migrations/0009_email_is_the_root_identity.sql
Normal file
66
packages/core/migrations/0009_email_is_the_root_identity.sql
Normal file
@@ -0,0 +1,66 @@
|
||||
-- One address, one account. ref(d-0048)
|
||||
--
|
||||
-- Runs against a database in which every user was created by signing in with a
|
||||
-- gaming account, which means most rows have no email at all and nothing has
|
||||
-- ever stopped two rows from sharing one. Three consequences, in order:
|
||||
--
|
||||
-- 1. The column is normalized first. The address is about to become an
|
||||
-- identity, so `Ada@Example.com ` and `ada@example.com` have to stop
|
||||
-- being two of them. Trimming and lower-casing happens here once; the
|
||||
-- code that writes the column does the same thing on the way in.
|
||||
-- 2. Duplicates are separated before the index exists, because
|
||||
-- `CREATE UNIQUE INDEX` fails outright on the first pair it meets, and a
|
||||
-- migration that dies half way through is worse than one that decides.
|
||||
-- 3. The index is partial. A null email is not a value, so the accounts that
|
||||
-- have none do not collide with each other — which is the only reason a
|
||||
-- unique index can land on these rows at all.
|
||||
|
||||
UPDATE "user"
|
||||
SET "email" = lower(btrim("email"))
|
||||
WHERE "email" IS NOT NULL
|
||||
AND "email" <> lower(btrim("email"));--> statement-breakpoint
|
||||
|
||||
-- Where two accounts claim one address, the older keeps it.
|
||||
--
|
||||
-- Nothing is deleted: both accounts survive, with their games, their hardware
|
||||
-- and their team. What the newer one loses is the address, and `email_verified`
|
||||
-- goes back to false to say so — the next sign-in asks for an address and the
|
||||
-- person supplies one, which is a prompt rather than a loss.
|
||||
--
|
||||
-- The older row wins because its address has been in use longest, so it is the
|
||||
-- one a receipt or a reset was most likely sent to. `id` breaks a tie on
|
||||
-- `time_created`, so the choice is total and re-running this changes nothing.
|
||||
UPDATE "user" u
|
||||
SET "email" = NULL, "email_verified" = false
|
||||
WHERE u."email" IS NOT NULL
|
||||
AND u."time_deleted" IS NULL
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM "user" older
|
||||
WHERE older."email" = u."email"
|
||||
AND older."time_deleted" IS NULL
|
||||
AND (older."time_created", older."id") < (u."time_created", u."id")
|
||||
);--> statement-breakpoint
|
||||
|
||||
CREATE UNIQUE INDEX "user_email_unique" ON "user" USING btree ("email") WHERE email is not null and time_deleted is null;--> statement-breakpoint
|
||||
|
||||
-- There is no constraint here for the cap on how many gaming accounts one
|
||||
-- person may connect, and there cannot be one.
|
||||
--
|
||||
-- A unique index makes a value unique; it cannot count the rows that share a
|
||||
-- foreign key, so no index shape says "at most four of these". The cap is
|
||||
-- enforced in application code, and a direct write to `linked_account` can
|
||||
-- exceed it. This is written where the schema is read so that nobody looks for
|
||||
-- the rule here, fails to find it, and concludes there is not one. Rows
|
||||
-- already over the cap are left alone: the limit governs connecting another,
|
||||
-- not keeping what is already connected.
|
||||
|
||||
-- Below is not part of the above, and carries no reason of its own.
|
||||
--
|
||||
-- It records which attempt holds a run: the agent generates an opaque value
|
||||
-- per claim, the row remembers the first one to arrive, and every later write
|
||||
-- has to present it. Nullable and unbackfilled, because a run nobody has
|
||||
-- claimed genuinely has no holder, and never cleared, because a finished run
|
||||
-- still has to say which attempt ran it. The endpoint that reads and writes it
|
||||
-- arrives separately; it is here because a schema change has one owner at a
|
||||
-- time.
|
||||
ALTER TABLE "session" ADD COLUMN "claim_token" text;
|
||||
2316
packages/core/migrations/meta/0009_snapshot.json
Normal file
2316
packages/core/migrations/meta/0009_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -64,6 +64,13 @@
|
||||
"when": 1788547836146,
|
||||
"tag": "0008_session_one_active_run_per_box",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 9,
|
||||
"version": "7",
|
||||
"when": 1788555252186,
|
||||
"tag": "0009_email_is_the_root_identity",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
177
packages/core/script/verify-migration-0009.sh
Executable file
177
packages/core/script/verify-migration-0009.sh
Executable file
@@ -0,0 +1,177 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# Prove this migration against a database built to look like the live one,
|
||||
# rather than against an empty schema.
|
||||
#
|
||||
# A migration that only ever runs on a database with no rows in it has
|
||||
# demonstrated nothing: every statement here that could go wrong goes wrong
|
||||
# because of what is already in the table. So this builds the awkward rows by
|
||||
# hand — an account with no address, two accounts sharing one address in
|
||||
# different cases, an account already over the connection cap, a soft-deleted
|
||||
# row holding an address a live row also holds — applies every migration
|
||||
# before this one, then applies this one and checks each of them individually.
|
||||
#
|
||||
# Usage: PGHOST=localhost PGPORT=5434 ./verify-migration-0009.sh
|
||||
set -euo pipefail
|
||||
|
||||
PGHOST="${PGHOST:-localhost}"
|
||||
PGPORT="${PGPORT:-5434}"
|
||||
PGUSER="${PGUSER:-postgres}"
|
||||
export PGPASSWORD="${PGPASSWORD:-postgres}"
|
||||
DB="${DB:-nestri_mig_0009}"
|
||||
|
||||
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
MIGRATIONS="$HERE/../migrations"
|
||||
|
||||
psql_admin() { psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d postgres -qtA "$@"; }
|
||||
psql_db() { psql -h "$PGHOST" -p "$PGPORT" -U "$PGUSER" -d "$DB" -qtA -v ON_ERROR_STOP=1 "$@"; }
|
||||
|
||||
failures=0
|
||||
check() { # check <name> <expected> <sql>
|
||||
local got
|
||||
got="$(psql_db -c "$3" | tr -d '[:space:]')"
|
||||
if [ "$got" = "$2" ]; then
|
||||
printf 'ok %s\n' "$1"
|
||||
else
|
||||
printf 'FAIL %s — expected %s, got %s\n' "$1" "$2" "$got"
|
||||
failures=$((failures + 1))
|
||||
fi
|
||||
}
|
||||
|
||||
echo "== rebuilding $DB =="
|
||||
psql_admin -c "drop database if exists $DB" >/dev/null
|
||||
psql_admin -c "create database $DB" >/dev/null
|
||||
|
||||
echo "== applying everything before it =="
|
||||
for f in "$MIGRATIONS"/000[0-8]_*.sql; do
|
||||
psql_db -f "$f" >/dev/null
|
||||
printf ' %s\n' "$(basename "$f")"
|
||||
done
|
||||
|
||||
echo "== seeding rows the way the live database actually looks =="
|
||||
psql_db >/dev/null <<'SQL'
|
||||
-- ids are char(30): a four-character prefix and 26 more.
|
||||
-- A: made by a gaming sign-in, no address at all. The ordinary case today.
|
||||
insert into "user" (id, name, email, email_verified, time_created) values
|
||||
('usr_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'no-email', null, false, now() - interval '10 days');
|
||||
insert into linked_account (id, user_id, provider, provider_account_id) values
|
||||
('lac_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'steam', '76561100000000001');
|
||||
|
||||
-- B: has both, and the address is stored with the case and spacing a person typed.
|
||||
insert into "user" (id, name, email, email_verified, time_created) values
|
||||
('usr_bbbbbbbbbbbbbbbbbbbbbbbbbb', 'both', ' Ada@Example.COM ', true, now() - interval '9 days');
|
||||
insert into linked_account (id, user_id, provider, provider_account_id) values
|
||||
('lac_bbbbbbbbbbbbbbbbbbbbbbbbbb', 'usr_bbbbbbbbbbbbbbbbbbbbbbbbbb', 'steam', '76561100000000002');
|
||||
|
||||
-- C: one person, two gaming accounts. Nothing may touch either.
|
||||
insert into "user" (id, name, email, email_verified, time_created) values
|
||||
('usr_cccccccccccccccccccccccccc', 'two-links', null, false, now() - interval '8 days');
|
||||
insert into linked_account (id, user_id, provider, provider_account_id) values
|
||||
('lac_cc1ccccccccccccccccccccccc', 'usr_cccccccccccccccccccccccccc', 'steam', '76561100000000003'),
|
||||
('lac_cc2ccccccccccccccccccccccc', 'usr_cccccccccccccccccccccccccc', 'steam', '76561100000000004');
|
||||
|
||||
-- D and E: two accounts on one address, spelled differently. Nothing ever
|
||||
-- stopped this, so a live database is entitled to contain it.
|
||||
insert into "user" (id, name, email, email_verified, time_created) values
|
||||
('usr_dddddddddddddddddddddddddd', 'older-dup', 'grace@example.com', true, now() - interval '7 days'),
|
||||
('usr_eeeeeeeeeeeeeeeeeeeeeeeeee', 'newer-dup', 'GRACE@example.com', true, now() - interval '6 days');
|
||||
insert into linked_account (id, user_id, provider, provider_account_id) values
|
||||
('lac_eeeeeeeeeeeeeeeeeeeeeeeeee', 'usr_eeeeeeeeeeeeeeeeeeeeeeeeee', 'steam', '76561100000000005');
|
||||
|
||||
-- F: already over the cap the application is about to start enforcing.
|
||||
insert into "user" (id, name, email, email_verified, time_created) values
|
||||
('usr_ffffffffffffffffffffffffff', 'over-cap', null, false, now() - interval '5 days');
|
||||
insert into linked_account (id, user_id, provider, provider_account_id) values
|
||||
('lac_ff1fffffffffffffffffffffff', 'usr_ffffffffffffffffffffffffff', 'steam', '76561100000000006'),
|
||||
('lac_ff2fffffffffffffffffffffff', 'usr_ffffffffffffffffffffffffff', 'steam', '76561100000000007'),
|
||||
('lac_ff3fffffffffffffffffffffff', 'usr_ffffffffffffffffffffffffff', 'steam', '76561100000000008'),
|
||||
('lac_ff4fffffffffffffffffffffff', 'usr_ffffffffffffffffffffffffff', 'steam', '76561100000000009'),
|
||||
('lac_ff5fffffffffffffffffffffff', 'usr_ffffffffffffffffffffffffff', 'steam', '76561100000000010');
|
||||
|
||||
-- G: a deleted account still holding an address a live account also holds. The
|
||||
-- index has to tolerate this or the migration fails on a row nobody can see.
|
||||
insert into "user" (id, name, email, email_verified, time_created, time_deleted) values
|
||||
('usr_gggggggggggggggggggggggggg', 'deleted-dup', 'grace@example.com', true, now() - interval '4 days', now());
|
||||
|
||||
-- A run in flight, so the new column lands on a row that already exists.
|
||||
insert into team (id, name, slug, owner_id) values
|
||||
('tem_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'T', 't', 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa');
|
||||
insert into team_member (id, team_id, user_id, role) values
|
||||
('mem_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'tem_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'owner');
|
||||
insert into machine (id, owner_user_id, team_id, label, secret_hash) values
|
||||
('mch_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'tem_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'host', 'hash');
|
||||
insert into game (id, steam_app_id, name, slug) values
|
||||
('gam_aaaaaaaaaaaaaaaaaaaaaaaaaa', 730, 'G', 'g');
|
||||
insert into box (id, user_id, machine_id, label) values
|
||||
('box_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'mch_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'b');
|
||||
insert into "session" (id, box_id, game_id, linked_account_id, state) values
|
||||
('ses_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'box_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'gam_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'lac_aaaaaaaaaaaaaaaaaaaaaaaaaa', 'live');
|
||||
SQL
|
||||
|
||||
before_users="$(psql_db -c 'select count(*) from "user"')"
|
||||
before_links="$(psql_db -c 'select count(*) from linked_account')"
|
||||
echo " $before_users users, $before_links connected accounts"
|
||||
|
||||
echo "== applying the migration under test =="
|
||||
psql_db -f "$MIGRATIONS/0009_email_is_the_root_identity.sql" >/dev/null
|
||||
echo " 0009_email_is_the_root_identity.sql"
|
||||
|
||||
echo "== checking =="
|
||||
check "no account was deleted" "$before_users" 'select count(*) from "user"'
|
||||
check "no connection was deleted" "$before_links" 'select count(*) from linked_account'
|
||||
|
||||
check "A: an account with no address is untouched" "t" \
|
||||
"select email is null and email_verified = false from \"user\" where id = 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa'"
|
||||
check "A: its connected account survives" "1" \
|
||||
"select count(*) from linked_account where user_id = 'usr_aaaaaaaaaaaaaaaaaaaaaaaaaa'"
|
||||
|
||||
check "B: the address is normalized in place" "ada@example.com" \
|
||||
"select email from \"user\" where id = 'usr_bbbbbbbbbbbbbbbbbbbbbbbbbb'"
|
||||
check "B: it stays verified" "t" \
|
||||
"select email_verified from \"user\" where id = 'usr_bbbbbbbbbbbbbbbbbbbbbbbbbb'"
|
||||
|
||||
check "C: two connected accounts are still two" "2" \
|
||||
"select count(*) from linked_account where user_id = 'usr_cccccccccccccccccccccccccc'"
|
||||
|
||||
check "D: the older of the pair keeps the address" "grace@example.com" \
|
||||
"select email from \"user\" where id = 'usr_dddddddddddddddddddddddddd'"
|
||||
check "D: and stays verified" "t" \
|
||||
"select email_verified from \"user\" where id = 'usr_dddddddddddddddddddddddddd'"
|
||||
check "E: the newer one loses it and is asked again" "t" \
|
||||
"select email is null and email_verified = false from \"user\" where id = 'usr_eeeeeeeeeeeeeeeeeeeeeeeeee'"
|
||||
check "E: but keeps its account and its connection" "1" \
|
||||
"select count(*) from linked_account where user_id = 'usr_eeeeeeeeeeeeeeeeeeeeeeeeee'"
|
||||
|
||||
check "F: an account already over the cap is left alone" "5" \
|
||||
"select count(*) from linked_account where user_id = 'usr_ffffffffffffffffffffffffff'"
|
||||
|
||||
check "G: a deleted row may keep a live row's address" "grace@example.com" \
|
||||
"select email from \"user\" where id = 'usr_gggggggggggggggggggggggggg'"
|
||||
|
||||
check "the index exists" "1" \
|
||||
"select count(*) from pg_indexes where indexname = 'user_email_unique'"
|
||||
# If the insert is allowed, the raise below is not a unique_violation, so it is
|
||||
# not caught, and psql stops on it — which reads as a failure rather than a pass.
|
||||
check "and a second live account cannot take a taken address" "refused" \
|
||||
"do \$\$ begin
|
||||
insert into \"user\" (id, name, email) values ('usr_zzzzzzzzzzzzzzzzzzzzzzzzzz', 'z', 'grace@example.com');
|
||||
raise exception 'the index allowed a duplicate address';
|
||||
exception when unique_violation then null;
|
||||
end \$\$; select 'refused'"
|
||||
|
||||
check "the claim column is there" "1" \
|
||||
"select count(*) from information_schema.columns where table_name = 'session' and column_name = 'claim_token'"
|
||||
check "the claim column is nullable" "YES" \
|
||||
"select is_nullable from information_schema.columns where table_name = 'session' and column_name = 'claim_token'"
|
||||
check "the claim column has no default" "1" \
|
||||
"select count(*) from information_schema.columns where table_name = 'session' and column_name = 'claim_token' and column_default is null"
|
||||
check "and nothing was backfilled into it" "1" \
|
||||
"select count(*) from \"session\" where claim_token is null"
|
||||
|
||||
echo
|
||||
if [ "$failures" -eq 0 ]; then
|
||||
echo "all checks passed"
|
||||
else
|
||||
echo "$failures check(s) failed"
|
||||
exit 1
|
||||
fi
|
||||
Reference in New Issue
Block a user