feat: synchronize account icons across game profiles
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
export interface AccountIconProjection {
|
||||
revision: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
}
|
||||
|
||||
export const isCanonicalIsoTimestamp = (value: string): boolean => {
|
||||
const parsed = new Date(value);
|
||||
return !Number.isNaN(parsed.getTime()) && parsed.toISOString() === value;
|
||||
};
|
||||
|
||||
export const parseAccountIconProjection = (value: unknown): AccountIconProjection => {
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
throw new Error('Account icon projection must be an object.');
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(record).length !== 3 ||
|
||||
!Object.hasOwn(record, 'revision') ||
|
||||
!Object.hasOwn(record, 'picture') ||
|
||||
!Object.hasOwn(record, 'imageServer') ||
|
||||
typeof record.revision !== 'string' ||
|
||||
!isCanonicalIsoTimestamp(record.revision) ||
|
||||
typeof record.picture !== 'string' ||
|
||||
record.picture.length === 0 ||
|
||||
typeof record.imageServer !== 'number' ||
|
||||
!Number.isSafeInteger(record.imageServer) ||
|
||||
record.imageServer < 0
|
||||
) {
|
||||
throw new Error('Account icon projection is invalid.');
|
||||
}
|
||||
return {
|
||||
revision: record.revision,
|
||||
picture: record.picture,
|
||||
imageServer: record.imageServer,
|
||||
};
|
||||
};
|
||||
|
||||
const canonicalRevision = (value: string | undefined): string | null =>
|
||||
value && isCanonicalIsoTimestamp(value) ? value : null;
|
||||
|
||||
export const resolveAccountIconProjection = (input: {
|
||||
createdAt: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
iconUpdatedAt?: string;
|
||||
iconRevision?: string;
|
||||
profileIconResetAt?: string;
|
||||
}): AccountIconProjection => {
|
||||
const iconRevision =
|
||||
canonicalRevision(input.iconRevision) ??
|
||||
canonicalRevision(input.iconUpdatedAt) ??
|
||||
canonicalRevision(input.createdAt);
|
||||
if (!iconRevision) {
|
||||
throw new Error('User account icon revision is invalid.');
|
||||
}
|
||||
const resetRevision = canonicalRevision(input.profileIconResetAt);
|
||||
if (resetRevision && resetRevision >= iconRevision) {
|
||||
return {
|
||||
revision: resetRevision,
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
};
|
||||
}
|
||||
if (!input.picture || !Number.isSafeInteger(input.imageServer) || input.imageServer < 0) {
|
||||
throw new Error('User account icon is invalid.');
|
||||
}
|
||||
return {
|
||||
revision: iconRevision,
|
||||
picture: input.picture,
|
||||
imageServer: input.imageServer,
|
||||
};
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto';
|
||||
|
||||
import { isCanonicalIsoTimestamp } from './accountIconProjection.js';
|
||||
|
||||
export interface UserSanctions {
|
||||
bannedUntil?: string;
|
||||
mutedUntil?: string;
|
||||
@@ -7,7 +9,6 @@ export interface UserSanctions {
|
||||
warningCount?: number;
|
||||
flags?: string[];
|
||||
notes?: string;
|
||||
profileIconResetAt?: string;
|
||||
serverRestrictions?: Record<string, UserServerRestriction>;
|
||||
legacyPenalty?: Record<string, unknown>;
|
||||
}
|
||||
@@ -26,6 +27,8 @@ export interface GatewayUserInfo {
|
||||
roles: string[];
|
||||
picture?: string;
|
||||
imageServer?: number;
|
||||
iconUpdatedAt?: string;
|
||||
profileIconResetAt?: string;
|
||||
canUseGeneralPicture?: boolean;
|
||||
createdAt?: string;
|
||||
legacyMemberNo?: number;
|
||||
@@ -90,6 +93,10 @@ export const parseGameSessionTokenPayload = (value: unknown): GameSessionTokenPa
|
||||
!Array.isArray(user.roles) ||
|
||||
(user.picture !== undefined && typeof user.picture !== 'string') ||
|
||||
(user.imageServer !== undefined && (!Number.isSafeInteger(user.imageServer) || user.imageServer < 0)) ||
|
||||
(user.iconUpdatedAt !== undefined &&
|
||||
(typeof user.iconUpdatedAt !== 'string' || !isCanonicalIsoTimestamp(user.iconUpdatedAt))) ||
|
||||
(user.profileIconResetAt !== undefined &&
|
||||
(typeof user.profileIconResetAt !== 'string' || !isCanonicalIsoTimestamp(user.profileIconResetAt))) ||
|
||||
(user.canUseGeneralPicture !== undefined && typeof user.canUseGeneralPicture !== 'boolean') ||
|
||||
(user.legacyMemberNo !== undefined && (!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0))
|
||||
) {
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from './turnDaemon/types.js';
|
||||
export * from './realtime/keys.js';
|
||||
export * from './realtime/types.js';
|
||||
export * from './ranking/types.js';
|
||||
export * from './auth/accountIconProjection.js';
|
||||
|
||||
@@ -205,6 +205,14 @@ export type TurnDaemonCommand =
|
||||
specialWar?: string;
|
||||
};
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralIcon';
|
||||
requestId?: string;
|
||||
userId: string;
|
||||
picture: string;
|
||||
imageServer: number;
|
||||
iconRevision: string;
|
||||
}
|
||||
| {
|
||||
type: 'joinCreateGeneral';
|
||||
requestId?: string;
|
||||
@@ -220,6 +228,7 @@ export type TurnDaemonCommand =
|
||||
profileId: string;
|
||||
ownerPicture?: string;
|
||||
ownerImageServer?: number;
|
||||
ownerIconRevision?: string;
|
||||
ownerCanUsePicture?: boolean;
|
||||
ownerLegacyPenalty?: Record<string, unknown>;
|
||||
inheritSpecial?: string;
|
||||
@@ -516,6 +525,18 @@ export type TurnDaemonCommandResult =
|
||||
generalId: number;
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralIcon';
|
||||
ok: true;
|
||||
generalId: number | null;
|
||||
updated: boolean;
|
||||
}
|
||||
| {
|
||||
type: 'adjustGeneralIcon';
|
||||
ok: false;
|
||||
code: 'CONFLICT' | 'PRECONDITION_FAILED';
|
||||
reason: string;
|
||||
}
|
||||
| {
|
||||
type: 'joinCreateGeneral';
|
||||
ok: true;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { resolveAccountIconProjection } from '../src/auth/accountIconProjection.js';
|
||||
|
||||
describe('account icon projection', () => {
|
||||
it('resolves the current account icon from its durable revision', () => {
|
||||
expect(
|
||||
resolveAccountIconProjection({
|
||||
createdAt: '2026-07-01T00:00:00.000Z',
|
||||
iconUpdatedAt: '2026-07-31T09:00:00.000Z',
|
||||
picture: 'account.png',
|
||||
imageServer: 1,
|
||||
})
|
||||
).toEqual({
|
||||
revision: '2026-07-31T09:00:00.000Z',
|
||||
picture: 'account.png',
|
||||
imageServer: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('lets an administrator reset win at the same or a later revision', () => {
|
||||
expect(
|
||||
resolveAccountIconProjection({
|
||||
createdAt: '2026-07-01T00:00:00.000Z',
|
||||
iconUpdatedAt: '2026-07-31T09:00:00.000Z',
|
||||
profileIconResetAt: '2026-07-31T09:00:00.000Z',
|
||||
picture: 'account.png',
|
||||
imageServer: 1,
|
||||
})
|
||||
).toEqual({
|
||||
revision: '2026-07-31T09:00:00.000Z',
|
||||
picture: 'default.jpg',
|
||||
imageServer: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ createdAt: 'not-a-date', picture: 'account.png', imageServer: 1 },
|
||||
{ createdAt: '2026-07-31T09:00:00.000Z', picture: '', imageServer: 1 },
|
||||
{ createdAt: '2026-07-31T09:00:00.000Z', picture: 'account.png', imageServer: -1 },
|
||||
])('rejects invalid durable account icon state: %j', (value) => {
|
||||
expect(() => resolveAccountIconProjection(value)).toThrow();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
decryptGameSessionToken,
|
||||
encryptGameSessionToken,
|
||||
parseGameSessionTokenPayload,
|
||||
type GameSessionTokenPayload,
|
||||
} from '../src/auth/gameToken.js';
|
||||
|
||||
const buildPayload = (): GameSessionTokenPayload => ({
|
||||
version: 1,
|
||||
profile: 'che:default',
|
||||
issuedAt: '2026-07-31T09:00:00.000Z',
|
||||
expiresAt: '2026-07-31T09:10:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
user: {
|
||||
id: 'user-1',
|
||||
username: 'tester',
|
||||
displayName: '테스트',
|
||||
roles: ['user'],
|
||||
picture: 'account-icon.png',
|
||||
imageServer: 1,
|
||||
iconUpdatedAt: '2026-07-31T08:59:00.000Z',
|
||||
},
|
||||
sanctions: {},
|
||||
});
|
||||
|
||||
describe('game session token account icon revision', () => {
|
||||
it('round-trips the canonical account icon revision', () => {
|
||||
const payload = buildPayload();
|
||||
const token = encryptGameSessionToken(payload, 'test-only-secret');
|
||||
|
||||
expect(decryptGameSessionToken(token, 'test-only-secret')).toEqual(payload);
|
||||
});
|
||||
|
||||
it.each([1, {}, '2026-07-31', '2026-07-31T08:59:00Z', 'not-a-date'])(
|
||||
'rejects a non-canonical icon revision: %j',
|
||||
(iconUpdatedAt) => {
|
||||
const payload = buildPayload() as unknown as {
|
||||
user: { iconUpdatedAt: unknown };
|
||||
};
|
||||
payload.user.iconUpdatedAt = iconUpdatedAt;
|
||||
|
||||
expect(parseGameSessionTokenPayload(payload)).toBeNull();
|
||||
}
|
||||
);
|
||||
});
|
||||
@@ -18,6 +18,7 @@
|
||||
"prisma:migrate:deploy:game": "prisma migrate deploy --schema prisma/game.prisma",
|
||||
"prisma:migrate:deploy:gateway": "PRISMA_SCHEMA=prisma/gateway.prisma prisma migrate deploy --schema prisma/gateway.prisma --config prisma.gateway.config.ts",
|
||||
"prisma:migrate:status:game": "prisma migrate status --schema prisma/game.prisma",
|
||||
"verify:migration:account-icon": "sh scripts/verify-account-icon-migration.sh",
|
||||
"verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh",
|
||||
"prisma:db:push:game": "prisma db push --schema prisma/game.prisma",
|
||||
"prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma"
|
||||
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE "app_user"
|
||||
ADD COLUMN "icon_revision" TIMESTAMP(3),
|
||||
ADD COLUMN "profile_icon_reset_at" TIMESTAMP(3);
|
||||
|
||||
-- Before this migration the administrator reset marker lived in the sanctions
|
||||
-- JSON. Refuse malformed non-string data instead of silently losing a reset;
|
||||
-- PostgreSQL's cast below likewise makes an invalid datetime fail the deploy.
|
||||
DO $$
|
||||
BEGIN
|
||||
IF EXISTS (
|
||||
SELECT 1
|
||||
FROM "app_user"
|
||||
WHERE "sanctions" ? 'profileIconResetAt'
|
||||
AND (
|
||||
jsonb_typeof("sanctions" -> 'profileIconResetAt') IS DISTINCT FROM 'string'
|
||||
OR NOT ("sanctions" ->> 'profileIconResetAt') ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}\.[0-9]{3}Z$'
|
||||
)
|
||||
) THEN
|
||||
RAISE EXCEPTION 'app_user.sanctions.profileIconResetAt must be an ISO datetime string';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
UPDATE "app_user"
|
||||
SET "profile_icon_reset_at" =
|
||||
(("sanctions" ->> 'profileIconResetAt')::TIMESTAMPTZ AT TIME ZONE 'UTC')
|
||||
WHERE "sanctions" ? 'profileIconResetAt';
|
||||
|
||||
UPDATE "app_user"
|
||||
SET "icon_revision" = GREATEST(
|
||||
"created_at",
|
||||
COALESCE("icon_updated_at", "created_at"),
|
||||
COALESCE("profile_icon_reset_at", "created_at")
|
||||
);
|
||||
|
||||
UPDATE "app_user"
|
||||
SET "sanctions" = "sanctions" - 'profileIconResetAt'
|
||||
WHERE "sanctions" ? 'profileIconResetAt';
|
||||
|
||||
COMMIT;
|
||||
@@ -73,6 +73,8 @@ model AppUser {
|
||||
picture String @default("default.jpg")
|
||||
imageServer Int @default(0) @map("image_server")
|
||||
iconUpdatedAt DateTime? @map("icon_updated_at")
|
||||
iconRevision DateTime? @map("icon_revision")
|
||||
profileIconResetAt DateTime? @map("profile_icon_reset_at")
|
||||
thirdPartyUse Boolean @default(true) @map("third_party_use")
|
||||
termsAcceptedAt DateTime? @map("terms_accepted_at")
|
||||
privacyAcceptedAt DateTime? @map("privacy_accepted_at")
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
: "${GATEWAY_MIGRATION_TEST_DATABASE_URL:?GATEWAY_MIGRATION_TEST_DATABASE_URL is required}"
|
||||
|
||||
script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
|
||||
package_dir=$(dirname "$script_dir")
|
||||
prisma_dir="$package_dir/prisma"
|
||||
target_migration=20260731001000_add_account_icon_revision
|
||||
run_id=$(date -u +%m%d%H%M%S)_$$
|
||||
predecessor_schema="gateway_icon_predecessor_$run_id"
|
||||
fresh_schema="gateway_icon_fresh_$run_id"
|
||||
ownership_token="sammo-gateway-icon-migration:$run_id"
|
||||
work_dir=$(mktemp -d "$package_dir/.gateway-icon-migration.XXXXXX")
|
||||
|
||||
cleanup() {
|
||||
cleanup_status=0
|
||||
OWNERSHIP_TOKEN=$ownership_token \
|
||||
SCHEMA_NAMES="$predecessor_schema,$fresh_schema" \
|
||||
DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
|
||||
pnpm --dir "$package_dir" exec node --input-type=module -e '
|
||||
import pg from "pg";
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
await client.connect();
|
||||
try {
|
||||
for (const schema of process.env.SCHEMA_NAMES.split(",")) {
|
||||
const ownership = await client.query(
|
||||
"SELECT obj_description(oid, $$pg_namespace$$) AS owner FROM pg_namespace WHERE nspname = $1",
|
||||
[schema]
|
||||
);
|
||||
if (ownership.rowCount === 0) continue;
|
||||
if (ownership.rows[0]?.owner !== process.env.OWNERSHIP_TOKEN) {
|
||||
throw new Error(`refusing to drop unowned schema: ${schema}`);
|
||||
}
|
||||
await client.query(`DROP SCHEMA ${quoteIdentifier(schema)} CASCADE`);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
' >/dev/null 2>&1 || cleanup_status=1
|
||||
case "$work_dir" in
|
||||
"$package_dir"/.gateway-icon-migration.*)
|
||||
rm -r -- "$work_dir" || cleanup_status=1
|
||||
;;
|
||||
*)
|
||||
echo "refusing to remove unsafe migration work directory: $work_dir" >&2
|
||||
cleanup_status=1
|
||||
;;
|
||||
esac
|
||||
return "$cleanup_status"
|
||||
}
|
||||
|
||||
handle_exit() {
|
||||
exit_status=$?
|
||||
trap - EXIT HUP INT TERM
|
||||
if ! cleanup && [ "$exit_status" -eq 0 ]; then
|
||||
exit_status=1
|
||||
fi
|
||||
exit "$exit_status"
|
||||
}
|
||||
|
||||
trap handle_exit EXIT
|
||||
trap 'exit 129' HUP
|
||||
trap 'exit 130' INT
|
||||
trap 'exit 143' TERM
|
||||
|
||||
[ -d "$prisma_dir/gateway-migrations/$target_migration" ] || {
|
||||
echo "target migration is missing: $target_migration" >&2
|
||||
exit 66
|
||||
}
|
||||
|
||||
build_database_url() {
|
||||
SCHEMA_NAME=$1 DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
|
||||
pnpm --dir "$package_dir" exec node --input-type=module -e '
|
||||
const url = new URL(process.env.DATABASE_URL);
|
||||
url.searchParams.set("schema", process.env.SCHEMA_NAME);
|
||||
process.stdout.write(url.href);
|
||||
'
|
||||
}
|
||||
|
||||
predecessor_url=$(build_database_url "$predecessor_schema")
|
||||
fresh_url=$(build_database_url "$fresh_schema")
|
||||
|
||||
OWNERSHIP_TOKEN=$ownership_token \
|
||||
SCHEMA_NAMES="$predecessor_schema,$fresh_schema" \
|
||||
DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
|
||||
pnpm --dir "$package_dir" exec node --input-type=module -e '
|
||||
import pg from "pg";
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
const apostrophe = String.fromCharCode(39);
|
||||
const quoteLiteral = (value) =>
|
||||
`${apostrophe}${value.replaceAll(apostrophe, apostrophe.repeat(2))}${apostrophe}`;
|
||||
await client.connect();
|
||||
try {
|
||||
for (const schema of process.env.SCHEMA_NAMES.split(",")) {
|
||||
await client.query(`CREATE SCHEMA ${quoteIdentifier(schema)}`);
|
||||
await client.query(
|
||||
`COMMENT ON SCHEMA ${quoteIdentifier(schema)} IS ${quoteLiteral(process.env.OWNERSHIP_TOKEN)}`
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
'
|
||||
|
||||
stage_dir="$work_dir/stage"
|
||||
mkdir -p "$stage_dir/gateway-migrations"
|
||||
cp "$prisma_dir/gateway.prisma" "$stage_dir/gateway.prisma"
|
||||
cat >"$stage_dir/prisma.config.ts" <<'EOF'
|
||||
import { defineConfig } from 'prisma/config';
|
||||
|
||||
export default defineConfig({
|
||||
schema: './gateway.prisma',
|
||||
migrations: { path: './gateway-migrations' },
|
||||
datasource: { url: process.env.GATEWAY_DATABASE_URL! },
|
||||
});
|
||||
EOF
|
||||
|
||||
found_target=0
|
||||
for migration_dir in "$prisma_dir"/gateway-migrations/[0-9]*; do
|
||||
migration_name=$(basename "$migration_dir")
|
||||
if [ "$migration_name" = "$target_migration" ]; then
|
||||
found_target=1
|
||||
break
|
||||
fi
|
||||
cp -R "$migration_dir" "$stage_dir/gateway-migrations/$migration_name"
|
||||
done
|
||||
[ "$found_target" -eq 1 ] || {
|
||||
echo "target migration was not found in migration order" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
cd "$package_dir"
|
||||
GATEWAY_DATABASE_URL=$predecessor_url \
|
||||
pnpm exec prisma migrate deploy --schema "$stage_dir/gateway.prisma" --config "$stage_dir/prisma.config.ts" \
|
||||
>"$work_dir/predecessor-deploy.log"
|
||||
|
||||
SCHEMA_NAME=$predecessor_schema DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
|
||||
pnpm exec node --input-type=module -e '
|
||||
import pg from "pg";
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
|
||||
await client.query(`
|
||||
INSERT INTO app_user (
|
||||
id, login_id, display_name, password_hash, password_salt,
|
||||
sanctions, icon_updated_at, created_at, updated_at
|
||||
) VALUES
|
||||
($1, $1, $1, $1, $1, $2::jsonb, $3, $4, $4),
|
||||
($5, $5, $5, $5, $5, $6::jsonb, $7, $4, $4),
|
||||
($8, $8, $8, $8, $8, $9::jsonb, NULL, $4, $4)
|
||||
`, [
|
||||
"legacy-reset",
|
||||
JSON.stringify({ profileIconResetAt: "2026-07-25T09:00:00.123Z", notes: "preserve" }),
|
||||
"2026-07-20T00:00:00.000Z",
|
||||
"2026-07-01T00:00:00.000Z",
|
||||
"ordinary-icon",
|
||||
JSON.stringify({ notes: "preserve" }),
|
||||
"2026-07-20T00:00:00.000Z",
|
||||
"malformed-reset",
|
||||
JSON.stringify({ profileIconResetAt: 1234, notes: "preserve" }),
|
||||
]);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
'
|
||||
|
||||
failure_log="$work_dir/malformed-deploy.log"
|
||||
if GATEWAY_DATABASE_URL=$predecessor_url \
|
||||
pnpm exec prisma migrate deploy --schema "$prisma_dir/gateway.prisma" --config "$package_dir/prisma.gateway.config.ts" \
|
||||
>"$failure_log" 2>&1; then
|
||||
echo "account icon migration unexpectedly accepted a malformed reset marker" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
SCHEMA_NAME=$predecessor_schema TARGET_MIGRATION=$target_migration DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
|
||||
pnpm exec node --input-type=module -e '
|
||||
import pg from "pg";
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
|
||||
const columns = await client.query(`
|
||||
SELECT count(*)::int AS count
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = $1
|
||||
AND table_name = $$app_user$$
|
||||
AND column_name IN ($$icon_revision$$, $$profile_icon_reset_at$$)
|
||||
`, [process.env.SCHEMA_NAME]);
|
||||
if (columns.rows[0].count !== 0) throw new Error("transactional DDL survived failed migration");
|
||||
const history = await client.query(`
|
||||
SELECT count(*)::int AS count,
|
||||
bool_and(finished_at IS NULL) AS unfinished,
|
||||
sum(applied_steps_count)::int AS steps
|
||||
FROM _prisma_migrations
|
||||
WHERE migration_name = $1
|
||||
`, [process.env.TARGET_MIGRATION]);
|
||||
const row = history.rows[0];
|
||||
if (row.count !== 1 || row.unfinished !== true || row.steps !== 0) {
|
||||
throw new Error(`unexpected failed migration history: ${JSON.stringify(row)}`);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
'
|
||||
|
||||
GATEWAY_DATABASE_URL=$predecessor_url \
|
||||
pnpm exec prisma migrate resolve --rolled-back "$target_migration" \
|
||||
--schema "$prisma_dir/gateway.prisma" --config "$package_dir/prisma.gateway.config.ts" \
|
||||
>"$work_dir/resolve.log"
|
||||
|
||||
SCHEMA_NAME=$predecessor_schema DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
|
||||
pnpm exec node --input-type=module -e '
|
||||
import pg from "pg";
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
|
||||
await client.query(`
|
||||
UPDATE app_user
|
||||
SET sanctions = jsonb_set(sanctions, ARRAY[$$profileIconResetAt$$], to_jsonb($1::text))
|
||||
WHERE id = $$malformed-reset$$
|
||||
`, ["2026-07-26T10:00:00.456Z"]);
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
'
|
||||
|
||||
GATEWAY_DATABASE_URL=$predecessor_url \
|
||||
pnpm exec prisma migrate deploy --schema "$prisma_dir/gateway.prisma" --config "$package_dir/prisma.gateway.config.ts" \
|
||||
>"$work_dir/recovered-deploy.log"
|
||||
GATEWAY_DATABASE_URL=$predecessor_url \
|
||||
pnpm exec prisma migrate deploy --schema "$prisma_dir/gateway.prisma" --config "$package_dir/prisma.gateway.config.ts" \
|
||||
>"$work_dir/noop-deploy.log"
|
||||
grep -Fq 'No pending migrations to apply' "$work_dir/noop-deploy.log"
|
||||
|
||||
SCHEMA_NAME=$predecessor_schema DATABASE_URL=$GATEWAY_MIGRATION_TEST_DATABASE_URL \
|
||||
pnpm exec node --input-type=module -e '
|
||||
import pg from "pg";
|
||||
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
||||
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
|
||||
const result = await client.query(`
|
||||
SELECT id,
|
||||
to_char(icon_revision, $$YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"$$) AS revision,
|
||||
CASE WHEN profile_icon_reset_at IS NULL THEN NULL
|
||||
ELSE to_char(profile_icon_reset_at, $$YYYY-MM-DD\"T\"HH24:MI:SS.MS\"Z\"$$)
|
||||
END AS reset,
|
||||
sanctions
|
||||
FROM app_user
|
||||
ORDER BY id
|
||||
`);
|
||||
const expected = [
|
||||
{ id: "legacy-reset", revision: "2026-07-25T09:00:00.123Z", reset: "2026-07-25T09:00:00.123Z", sanctions: { notes: "preserve" } },
|
||||
{ id: "malformed-reset", revision: "2026-07-26T10:00:00.456Z", reset: "2026-07-26T10:00:00.456Z", sanctions: { notes: "preserve" } },
|
||||
{ id: "ordinary-icon", revision: "2026-07-20T00:00:00.000Z", reset: null, sanctions: { notes: "preserve" } },
|
||||
];
|
||||
if (JSON.stringify(result.rows) !== JSON.stringify(expected)) {
|
||||
throw new Error(`unexpected account icon backfill: ${JSON.stringify(result.rows)}`);
|
||||
}
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
'
|
||||
|
||||
GATEWAY_DATABASE_URL=$fresh_url \
|
||||
pnpm exec prisma migrate deploy --schema "$prisma_dir/gateway.prisma" --config "$package_dir/prisma.gateway.config.ts" \
|
||||
>"$work_dir/fresh-deploy.log"
|
||||
GATEWAY_DATABASE_URL=$fresh_url \
|
||||
pnpm exec prisma migrate deploy --schema "$prisma_dir/gateway.prisma" --config "$package_dir/prisma.gateway.config.ts" \
|
||||
>"$work_dir/fresh-noop-deploy.log"
|
||||
grep -Fq 'No pending migrations to apply' "$work_dir/fresh-noop-deploy.log"
|
||||
|
||||
echo "Gateway account icon migration backfill, rollback, recovery, and fresh deploy passed"
|
||||
Reference in New Issue
Block a user