feat(migrations): reconcile user profile missed by initial-schema backfill (B5)
Folds the union of DDL effects from `scripts/add-user-profile-columns.js` (#8) and `scripts/add-user-profile-fields.js` (#9) into the migration history so a brand-new Neon branch reaches structural parity with prod. Captures: - 16 ALTER TABLE users ADD COLUMN IF NOT EXISTS (first_name, last_name, username UNIQUE, profile_image_url, bio, avatar_url, favorite_games, collection_visibility, preferred_currency, cards_per_page, default_view, notifications_email, notifications_marketing, two_factor_enabled, theme, language). Per Finding 2, BOTH profile_image_url and avatar_url are kept (cleanup deferred to queued unify-user-avatar-column). - 2 CREATE TABLE IF NOT EXISTS (user_settings, user_avatars) — FK to users with ON DELETE CASCADE; user_settings has UNIQUE(user_id, setting_key). - 6 CREATE INDEX IF NOT EXISTS (idx_users_username, idx_users_email, idx_user_settings_user_id, idx_user_settings_key, idx_user_avatars_user_id, idx_user_avatars_active). - 6 CHECK constraints wrapped in DO $$ EXCEPTION WHEN duplicate_object blocks (Postgres pre-15 has no ADD CONSTRAINT IF NOT EXISTS for CHECK): check_collection_visibility, check_preferred_currency, check_cards_per_page, check_default_view, check_theme, check_language. Defaults-backfill DML from the historical script is intentionally NOT replicated; column DEFAULTs handle fresh-env semantics and prod rows already have the values from the historical run. down() is a hard stub (rolling back would drop columns runtime code reads). Convoy: reconcile-historical-add-scripts (Brief 5/7). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
67073aab7f
commit
5db58baa93
1 changed files with 191 additions and 0 deletions
191
migrations/1781000000005_reconcile-user-profile.js
Normal file
191
migrations/1781000000005_reconcile-user-profile.js
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
/**
|
||||
* Reconcile user profile schema (B5 of reconcile-historical-add-scripts).
|
||||
*
|
||||
* Captures the union of DDL effects from two historical scripts that ran
|
||||
* against every long-lived env but were never folded into the migration
|
||||
* history:
|
||||
*
|
||||
* - `scripts/add-user-profile-columns.js` (script #8) — adds `first_name`,
|
||||
* `last_name`, `username UNIQUE`, `profile_image_url`.
|
||||
* - `scripts/add-user-profile-fields.js` (script #9) — adds the same first
|
||||
* three plus `bio`, `avatar_url`, 10 preference / notification / display
|
||||
* columns, two new tables (`user_settings`, `user_avatars`), 6 CHECK
|
||||
* constraints, and 6 indexes.
|
||||
*
|
||||
* Per the convoy's Finding 2, BOTH `profile_image_url` (from #8) AND
|
||||
* `avatar_url` (from #9) coexist in prod and are read by runtime code
|
||||
* (`pages/api/auth/register.js` reads `profile_image_url`; the user/avatar
|
||||
* surface reads `avatar_url`). The redundancy is documented as a separate
|
||||
* follow-up convoy `unify-user-avatar-column`; this migration must NOT pick
|
||||
* one and drop the other.
|
||||
*
|
||||
* Idempotency (D2):
|
||||
* - Every column ADD uses `ADD COLUMN IF NOT EXISTS`.
|
||||
* - Both new tables use `CREATE TABLE IF NOT EXISTS`.
|
||||
* - Every index uses `CREATE INDEX IF NOT EXISTS`.
|
||||
* - CHECK constraints are wrapped in `DO $$ ... EXCEPTION WHEN
|
||||
* duplicate_object THEN NULL; END $$;` blocks because Postgres pre-15
|
||||
* does not accept `ADD CONSTRAINT ... IF NOT EXISTS` for CHECK
|
||||
* constraints (matches the historical script's try/catch pattern, R5).
|
||||
*
|
||||
* Defaults-backfill DML (the `UPDATE users SET ... WHERE ... IS NULL` block
|
||||
* at the end of script #9) is intentionally NOT replicated. The column
|
||||
* DEFAULT clauses are sufficient on a fresh env (every new row gets the
|
||||
* default at insert time) and prod-existing rows already have the values
|
||||
* from the historical script's run.
|
||||
*
|
||||
* Down-migration is a hard stub. This is a reconciliation migration; rolling
|
||||
* it back on prod would drop columns that runtime code reads.
|
||||
*
|
||||
* @type {import('node-pg-migrate').ColumnDefinitions | undefined}
|
||||
*/
|
||||
export const shorthands = undefined;
|
||||
|
||||
/**
|
||||
* @param {import('node-pg-migrate').MigrationBuilder} pgm
|
||||
* @returns {void}
|
||||
*/
|
||||
export const up = (pgm) => {
|
||||
pgm.sql(`
|
||||
ALTER TABLE users
|
||||
ADD COLUMN IF NOT EXISTS first_name VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS last_name VARCHAR(255),
|
||||
ADD COLUMN IF NOT EXISTS username VARCHAR(255) UNIQUE,
|
||||
ADD COLUMN IF NOT EXISTS profile_image_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS bio TEXT,
|
||||
ADD COLUMN IF NOT EXISTS avatar_url TEXT,
|
||||
ADD COLUMN IF NOT EXISTS favorite_games JSONB DEFAULT '["MTG"]',
|
||||
ADD COLUMN IF NOT EXISTS collection_visibility VARCHAR(20) DEFAULT 'private',
|
||||
ADD COLUMN IF NOT EXISTS preferred_currency VARCHAR(3) DEFAULT 'USD',
|
||||
ADD COLUMN IF NOT EXISTS cards_per_page INTEGER DEFAULT 50,
|
||||
ADD COLUMN IF NOT EXISTS default_view VARCHAR(10) DEFAULT 'grid',
|
||||
ADD COLUMN IF NOT EXISTS notifications_email BOOLEAN DEFAULT true,
|
||||
ADD COLUMN IF NOT EXISTS notifications_marketing BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS two_factor_enabled BOOLEAN DEFAULT false,
|
||||
ADD COLUMN IF NOT EXISTS theme VARCHAR(10) DEFAULT 'system',
|
||||
ADD COLUMN IF NOT EXISTS language VARCHAR(5) DEFAULT 'en';
|
||||
`);
|
||||
|
||||
pgm.sql(`
|
||||
CREATE TABLE IF NOT EXISTS user_settings (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
setting_key VARCHAR(100) NOT NULL,
|
||||
setting_value JSONB NOT NULL,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, setting_key)
|
||||
);
|
||||
`);
|
||||
|
||||
pgm.sql(`
|
||||
CREATE TABLE IF NOT EXISTS user_avatars (
|
||||
id SERIAL PRIMARY KEY,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
filename VARCHAR(255) NOT NULL,
|
||||
original_name VARCHAR(255),
|
||||
mime_type VARCHAR(100),
|
||||
file_size INTEGER,
|
||||
file_path TEXT NOT NULL,
|
||||
is_active BOOLEAN DEFAULT true,
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`);
|
||||
|
||||
pgm.sql(`
|
||||
CREATE INDEX IF NOT EXISTS idx_users_username ON users(username);
|
||||
CREATE INDEX IF NOT EXISTS idx_users_email ON users(email);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_settings_key ON user_settings(setting_key);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_avatars_user_id ON user_avatars(user_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_user_avatars_active ON user_avatars(user_id, is_active);
|
||||
`);
|
||||
|
||||
pgm.sql(`
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT check_collection_visibility
|
||||
CHECK (collection_visibility IN ('private', 'public', 'unlisted'));
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END$$;
|
||||
`);
|
||||
|
||||
pgm.sql(`
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT check_preferred_currency
|
||||
CHECK (preferred_currency IN ('USD', 'EUR', 'GBP', 'CAD', 'JPY'));
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END$$;
|
||||
`);
|
||||
|
||||
pgm.sql(`
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT check_cards_per_page
|
||||
CHECK (cards_per_page IN (25, 50, 100));
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END$$;
|
||||
`);
|
||||
|
||||
pgm.sql(`
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT check_default_view
|
||||
CHECK (default_view IN ('grid', 'list'));
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END$$;
|
||||
`);
|
||||
|
||||
pgm.sql(`
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT check_theme
|
||||
CHECK (theme IN ('light', 'dark', 'system'));
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END$$;
|
||||
`);
|
||||
|
||||
pgm.sql(`
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE users
|
||||
ADD CONSTRAINT check_language
|
||||
CHECK (language IN ('en', 'es', 'fr', 'de', 'ja'));
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END$$;
|
||||
`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Down-migration is a hard stub. Rolling back this reconciliation would drop
|
||||
* columns that runtime code reads (e.g. `pages/api/auth/register.js` writes
|
||||
* and reads `profile_image_url`; `pages/api/user/settings.js` reads
|
||||
* `theme` / `language` / etc.) and would also drop the `user_settings` and
|
||||
* `user_avatars` tables along with any rows in them. If you need to roll
|
||||
* forward instead, write a NEW dated migration with a real `down`.
|
||||
*
|
||||
* @returns {void}
|
||||
*/
|
||||
export const down = () => {
|
||||
throw new Error(
|
||||
'[migration:1781000000005_reconcile-user-profile] Refusing to roll back. ' +
|
||||
'This is a reconciliation migration that captures DDL already in prod via ' +
|
||||
'scripts/add-user-profile-columns.js and scripts/add-user-profile-fields.js; ' +
|
||||
'rolling it back would drop columns that runtime code reads (profile_image_url, ' +
|
||||
'avatar_url, theme, language, etc.) and the user_settings / user_avatars tables. ' +
|
||||
'Write a new dated migration if a real schema correction is needed.'
|
||||
);
|
||||
};
|
||||
Loading…
Reference in a new issue