KST 세션에서 ReadModel과 WebPush outbox의 producer provenance를 분리하고 due, lease, retention 비교를 UTC wall 계약으로 맞춘다. 기존 TIMESTAMP(3) 타입과 이전 DML 형태를 유지하면서 populated upgrade, pending requeue, delivered 보존과 두 번째 deploy no-op을 자동 검증한다.
597 lines
27 KiB
Bash
Executable File
597 lines
27 KiB
Bash
Executable File
#!/bin/sh
|
|
set -eu
|
|
|
|
: "${GAME_OUTBOX_MIGRATION_TEST_DATABASE_URL:?GAME_OUTBOX_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=20260824070000_game_outbox_utc_wall_timestamps
|
|
run_id=$(date -u +%m%d%H%M%S)_$$
|
|
schema_name="game_outbox_utc_upgrade_$run_id"
|
|
ownership_token="sammo-game-outbox-utc-migration:$run_id"
|
|
work_dir=$(mktemp -d "$package_dir/.game-outbox-utc-migration.XXXXXX")
|
|
|
|
case "$schema_name" in
|
|
game_outbox_utc_upgrade_[0-9]*_[0-9]*) ;;
|
|
*)
|
|
echo "unsafe outbox migration schema name" >&2
|
|
exit 64
|
|
;;
|
|
esac
|
|
|
|
cleanup() {
|
|
cleanup_status=0
|
|
OWNERSHIP_TOKEN=$ownership_token \
|
|
SCHEMA_NAME=$schema_name \
|
|
DATABASE_URL=$GAME_OUTBOX_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 {
|
|
const ownership = await client.query(
|
|
"SELECT obj_description(oid, $$pg_namespace$$) AS owner FROM pg_namespace WHERE nspname = $1",
|
|
[process.env.SCHEMA_NAME]
|
|
);
|
|
if (ownership.rowCount > 0) {
|
|
if (ownership.rows[0]?.owner !== process.env.OWNERSHIP_TOKEN) {
|
|
throw new Error(`refusing to drop unowned schema: ${process.env.SCHEMA_NAME}`);
|
|
}
|
|
await client.query(`DROP SCHEMA ${quoteIdentifier(process.env.SCHEMA_NAME)} CASCADE`);
|
|
}
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
' >/dev/null 2>&1 || cleanup_status=1
|
|
case "$work_dir" in
|
|
"$package_dir"/.game-outbox-utc-migration.*)
|
|
rm -r -- "$work_dir" || cleanup_status=1
|
|
;;
|
|
*)
|
|
echo "refusing to remove unsafe outbox 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/migrations/$target_migration" ] || {
|
|
echo "target migration is missing: $target_migration" >&2
|
|
exit 66
|
|
}
|
|
|
|
build_database_url() {
|
|
SCHEMA_NAME=$schema_name DATABASE_URL=$GAME_OUTBOX_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);
|
|
// The migration must reinterpret the predecessor DB-default timestamps
|
|
// from the real legacy KST session contract, independently of host TZ.
|
|
url.searchParams.set("options", "-c TimeZone=Asia/Seoul");
|
|
process.stdout.write(url.href);
|
|
'
|
|
}
|
|
|
|
database_url=$(build_database_url)
|
|
|
|
OWNERSHIP_TOKEN=$ownership_token \
|
|
SCHEMA_NAME=$schema_name \
|
|
DATABASE_URL=$GAME_OUTBOX_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 {
|
|
await client.query("BEGIN");
|
|
await client.query(`CREATE SCHEMA ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
|
|
await client.query(
|
|
`COMMENT ON SCHEMA ${quoteIdentifier(process.env.SCHEMA_NAME)} IS ${quoteLiteral(
|
|
process.env.OWNERSHIP_TOKEN
|
|
)}`
|
|
);
|
|
await client.query("COMMIT");
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
throw error;
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
'
|
|
|
|
stage_prisma="$work_dir/prisma"
|
|
mkdir -p "$stage_prisma/migrations"
|
|
cp "$prisma_dir/game.prisma" "$stage_prisma/game.prisma"
|
|
found_target=0
|
|
for migration_dir in "$prisma_dir"/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_prisma/migrations/$migration_name"
|
|
done
|
|
[ "$found_target" -eq 1 ] || {
|
|
echo "target migration was not found in migration order" >&2
|
|
exit 1
|
|
}
|
|
|
|
cd "$package_dir"
|
|
if ! DATABASE_URL=$database_url PRISMA_SCHEMA="$stage_prisma/game.prisma" \
|
|
pnpm exec prisma migrate deploy --schema "$stage_prisma/game.prisma" \
|
|
>"$work_dir/predecessor-deploy.log" 2>&1; then
|
|
echo "failed to deploy the predecessor game migration chain" >&2
|
|
exit 1
|
|
fi
|
|
|
|
SCHEMA_NAME=$schema_name DATABASE_URL=$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 {
|
|
const timezone = await client.query("SHOW TimeZone");
|
|
if (timezone.rows[0]?.TimeZone !== "Asia/Seoul") {
|
|
throw new Error(`expected Asia/Seoul fixture session, received ${timezone.rows[0]?.TimeZone}`);
|
|
}
|
|
await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
|
|
await client.query("BEGIN");
|
|
await client.query(`
|
|
CREATE TABLE "_game_outbox_utc_upgrade_probe" (
|
|
"id" INTEGER PRIMARY KEY,
|
|
"before_utc" TIMESTAMP(3) NOT NULL,
|
|
"read_pending_created_before" TIMESTAMP(3)
|
|
)
|
|
`);
|
|
await client.query(`
|
|
INSERT INTO "_game_outbox_utc_upgrade_probe" ("id", "before_utc")
|
|
VALUES (1, CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$)
|
|
`);
|
|
await client.query(`
|
|
INSERT INTO "read_model_outbox" (
|
|
"id", "payload", "attempts", "available_at", "locked_at",
|
|
"lock_owner", "delivered_at", "last_error", "created_at"
|
|
) VALUES
|
|
(
|
|
910001,
|
|
$json$ {"version":1,"changes":[["general",101,"2"]],"fixture":"read-pending"} $json$::jsonb,
|
|
2,
|
|
TIMESTAMP $$2030-01-02 03:04:05.111$$,
|
|
TIMESTAMP $$2026-08-24 16:01:02.222$$,
|
|
$$legacy-read-worker$$,
|
|
NULL,
|
|
$$read retry$$,
|
|
DEFAULT
|
|
),
|
|
(
|
|
910002,
|
|
$json$ {"version":1,"changes":[["nation",7,"3"]],"fixture":"read-delivered"} $json$::jsonb,
|
|
3,
|
|
TIMESTAMP $$2026-08-24 15:00:00.333$$,
|
|
TIMESTAMP $$2026-08-24 15:01:00.444$$,
|
|
$$delivered-read-worker$$,
|
|
TIMESTAMP $$2026-08-24 15:02:00.555$$,
|
|
$$delivered read marker$$,
|
|
TIMESTAMP $$2026-08-24 16:10:00.789$$
|
|
)
|
|
`);
|
|
await client.query(`
|
|
INSERT INTO "web_push_outbox" (
|
|
"id", "event_id", "event_type", "user_ids", "year", "month",
|
|
"attempts", "available_at", "locked_at", "lock_owner",
|
|
"delivered_at", "last_error", "created_at"
|
|
) VALUES
|
|
(
|
|
920001,
|
|
$$upgrade-web-pending$$,
|
|
$$PRIVATE_MESSAGE_RECEIVED$$,
|
|
ARRAY[$$user-b$$, $$user-a$$],
|
|
201,
|
|
7,
|
|
4,
|
|
TIMESTAMP $$2030-02-03 04:05:06.222$$,
|
|
TIMESTAMP $$2026-08-24 07:02:03.333$$,
|
|
$$legacy-web-worker$$,
|
|
NULL,
|
|
$$web retry$$,
|
|
TIMESTAMP $$2026-08-24 07:00:00.456$$
|
|
),
|
|
(
|
|
920002,
|
|
$$upgrade-web-delivered$$,
|
|
$$MONTH_CHANGED$$,
|
|
ARRAY[$$user-c$$],
|
|
202,
|
|
8,
|
|
5,
|
|
TIMESTAMP $$2026-08-24 07:10:00.333$$,
|
|
TIMESTAMP $$2026-08-24 07:11:00.444$$,
|
|
$$delivered-web-worker$$,
|
|
TIMESTAMP $$2026-08-24 07:12:00.555$$,
|
|
$$delivered web marker$$,
|
|
TIMESTAMP $$2026-08-24 07:09:00.789$$
|
|
)
|
|
`);
|
|
await client.query(`
|
|
UPDATE "_game_outbox_utc_upgrade_probe"
|
|
SET "read_pending_created_before" = (
|
|
SELECT "created_at" FROM "read_model_outbox" WHERE "id" = 910001
|
|
)
|
|
WHERE "id" = 1
|
|
`);
|
|
const readProvenance = await client.query(`
|
|
SELECT read."created_at" = probe."before_utc" + INTERVAL $$9 hours$$ AS "isKstDefault"
|
|
FROM "read_model_outbox" read
|
|
CROSS JOIN "_game_outbox_utc_upgrade_probe" probe
|
|
WHERE read."id" = 910001
|
|
`);
|
|
if (readProvenance.rows[0]?.isKstDefault !== true) {
|
|
throw new Error("predecessor read-model created_at did not use the KST database default");
|
|
}
|
|
await client.query("COMMIT");
|
|
} catch (error) {
|
|
await client.query("ROLLBACK");
|
|
throw error;
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
'
|
|
|
|
if ! DATABASE_URL=$database_url PRISMA_SCHEMA="$prisma_dir/game.prisma" \
|
|
pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" \
|
|
>"$work_dir/incremental-deploy.log" 2>&1; then
|
|
echo "failed to deploy the outbox UTC-wall migration" >&2
|
|
exit 1
|
|
fi
|
|
|
|
SCHEMA_NAME=$schema_name \
|
|
TARGET_MIGRATION=$target_migration \
|
|
MIGRATION_FILE="$prisma_dir/migrations/$target_migration/migration.sql" \
|
|
DATABASE_URL=$database_url \
|
|
pnpm exec node --input-type=module -e '
|
|
import { createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import pg from "pg";
|
|
|
|
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
|
const quoteIdentifier = (value) => `"${value.replaceAll("\"", "\"\"")}"`;
|
|
const canonicalize = (value) => {
|
|
if (Array.isArray(value)) return value.map(canonicalize);
|
|
if (value && typeof value === "object") {
|
|
return Object.fromEntries(
|
|
Object.entries(value)
|
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
.map(([key, entry]) => [key, canonicalize(entry)])
|
|
);
|
|
}
|
|
return value;
|
|
};
|
|
const assertEqual = (actual, expected, label) => {
|
|
if (JSON.stringify(canonicalize(actual)) !== JSON.stringify(canonicalize(expected))) {
|
|
throw new Error(`${label}: expected ${JSON.stringify(expected)}, received ${JSON.stringify(actual)}`);
|
|
}
|
|
};
|
|
|
|
await client.connect();
|
|
try {
|
|
await client.query(`SET search_path TO ${quoteIdentifier(process.env.SCHEMA_NAME)}`);
|
|
|
|
const readRows = await client.query(`
|
|
SELECT
|
|
"read_model_outbox"."id"::int AS "id",
|
|
"payload",
|
|
"attempts",
|
|
to_char("available_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) AS "availableAt",
|
|
CASE WHEN "locked_at" IS NULL THEN NULL
|
|
ELSE to_char("locked_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) END AS "lockedAt",
|
|
"lock_owner" AS "lockOwner",
|
|
CASE WHEN "delivered_at" IS NULL THEN NULL
|
|
ELSE to_char("delivered_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) END AS "deliveredAt",
|
|
"last_error" AS "lastError",
|
|
to_char("created_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) AS "createdAt",
|
|
"created_at" = (
|
|
probe."read_pending_created_before" AT TIME ZONE $$Asia/Seoul$$
|
|
) AT TIME ZONE $$UTC$$ AS "createdNormalized",
|
|
"available_at" >= probe."before_utc" - INTERVAL $$1 second$$
|
|
AND "available_at" <= (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$
|
|
AS "availableInMigrationWindow"
|
|
FROM "read_model_outbox"
|
|
CROSS JOIN "_game_outbox_utc_upgrade_probe" probe
|
|
WHERE "read_model_outbox"."id" IN (910001, 910002)
|
|
ORDER BY "read_model_outbox"."id"
|
|
`);
|
|
const [readPending, readDelivered] = readRows.rows;
|
|
if (!readPending || !readDelivered) throw new Error("read-model upgrade fixtures are missing");
|
|
assertEqual(
|
|
{
|
|
id: readPending.id,
|
|
payload: readPending.payload,
|
|
attempts: readPending.attempts,
|
|
lockedAt: readPending.lockedAt,
|
|
lockOwner: readPending.lockOwner,
|
|
deliveredAt: readPending.deliveredAt,
|
|
lastError: readPending.lastError,
|
|
createdNormalized: readPending.createdNormalized,
|
|
availableInMigrationWindow: readPending.availableInMigrationWindow,
|
|
},
|
|
{
|
|
id: 910001,
|
|
payload: { version: 1, changes: [["general", 101, "2"]], fixture: "read-pending" },
|
|
attempts: 2,
|
|
lockedAt: null,
|
|
lockOwner: null,
|
|
deliveredAt: null,
|
|
lastError: "read retry",
|
|
createdNormalized: true,
|
|
availableInMigrationWindow: true,
|
|
},
|
|
"pending read-model row"
|
|
);
|
|
assertEqual(
|
|
{
|
|
id: readDelivered.id,
|
|
payload: readDelivered.payload,
|
|
attempts: readDelivered.attempts,
|
|
availableAt: readDelivered.availableAt,
|
|
lockedAt: readDelivered.lockedAt,
|
|
lockOwner: readDelivered.lockOwner,
|
|
deliveredAt: readDelivered.deliveredAt,
|
|
lastError: readDelivered.lastError,
|
|
createdAt: readDelivered.createdAt,
|
|
},
|
|
{
|
|
id: 910002,
|
|
payload: { version: 1, changes: [["nation", 7, "3"]], fixture: "read-delivered" },
|
|
attempts: 3,
|
|
availableAt: "2026-08-24 15:00:00.333",
|
|
lockedAt: "2026-08-24 15:01:00.444",
|
|
lockOwner: "delivered-read-worker",
|
|
deliveredAt: "2026-08-24 15:02:00.555",
|
|
lastError: "delivered read marker",
|
|
createdAt: "2026-08-24 07:10:00.789",
|
|
},
|
|
"delivered read-model row"
|
|
);
|
|
|
|
const webRows = await client.query(`
|
|
SELECT
|
|
"web_push_outbox"."id"::int AS "id",
|
|
"event_id" AS "eventId",
|
|
"event_type" AS "eventType",
|
|
"user_ids" AS "userIds",
|
|
"year",
|
|
"month",
|
|
"attempts",
|
|
to_char("available_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) AS "availableAt",
|
|
CASE WHEN "locked_at" IS NULL THEN NULL
|
|
ELSE to_char("locked_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) END AS "lockedAt",
|
|
"lock_owner" AS "lockOwner",
|
|
CASE WHEN "delivered_at" IS NULL THEN NULL
|
|
ELSE to_char("delivered_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) END AS "deliveredAt",
|
|
"last_error" AS "lastError",
|
|
to_char("created_at", $$YYYY-MM-DD HH24:MI:SS.MS$$) AS "createdAt",
|
|
"available_at" >= probe."before_utc" - INTERVAL $$1 second$$
|
|
AND "available_at" <= (CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$
|
|
AS "availableInMigrationWindow"
|
|
FROM "web_push_outbox"
|
|
CROSS JOIN "_game_outbox_utc_upgrade_probe" probe
|
|
WHERE "web_push_outbox"."id" IN (920001, 920002)
|
|
ORDER BY "web_push_outbox"."id"
|
|
`);
|
|
const [webPending, webDelivered] = webRows.rows;
|
|
if (!webPending || !webDelivered) throw new Error("web-push upgrade fixtures are missing");
|
|
assertEqual(
|
|
{
|
|
id: webPending.id,
|
|
eventId: webPending.eventId,
|
|
eventType: webPending.eventType,
|
|
userIds: webPending.userIds,
|
|
year: webPending.year,
|
|
month: webPending.month,
|
|
attempts: webPending.attempts,
|
|
lockedAt: webPending.lockedAt,
|
|
lockOwner: webPending.lockOwner,
|
|
deliveredAt: webPending.deliveredAt,
|
|
lastError: webPending.lastError,
|
|
createdAt: webPending.createdAt,
|
|
availableInMigrationWindow: webPending.availableInMigrationWindow,
|
|
},
|
|
{
|
|
id: 920001,
|
|
eventId: "upgrade-web-pending",
|
|
eventType: "PRIVATE_MESSAGE_RECEIVED",
|
|
userIds: ["user-b", "user-a"],
|
|
year: 201,
|
|
month: 7,
|
|
attempts: 4,
|
|
lockedAt: null,
|
|
lockOwner: null,
|
|
deliveredAt: null,
|
|
lastError: "web retry",
|
|
createdAt: "2026-08-24 07:00:00.456",
|
|
availableInMigrationWindow: true,
|
|
},
|
|
"pending web-push row"
|
|
);
|
|
assertEqual(
|
|
{
|
|
id: webDelivered.id,
|
|
eventId: webDelivered.eventId,
|
|
eventType: webDelivered.eventType,
|
|
userIds: webDelivered.userIds,
|
|
year: webDelivered.year,
|
|
month: webDelivered.month,
|
|
attempts: webDelivered.attempts,
|
|
availableAt: webDelivered.availableAt,
|
|
lockedAt: webDelivered.lockedAt,
|
|
lockOwner: webDelivered.lockOwner,
|
|
deliveredAt: webDelivered.deliveredAt,
|
|
lastError: webDelivered.lastError,
|
|
createdAt: webDelivered.createdAt,
|
|
},
|
|
{
|
|
id: 920002,
|
|
eventId: "upgrade-web-delivered",
|
|
eventType: "MONTH_CHANGED",
|
|
userIds: ["user-c"],
|
|
year: 202,
|
|
month: 8,
|
|
attempts: 5,
|
|
availableAt: "2026-08-24 07:10:00.333",
|
|
lockedAt: "2026-08-24 07:11:00.444",
|
|
lockOwner: "delivered-web-worker",
|
|
deliveredAt: "2026-08-24 07:12:00.555",
|
|
lastError: "delivered web marker",
|
|
createdAt: "2026-08-24 07:09:00.789",
|
|
},
|
|
"delivered web-push row"
|
|
);
|
|
|
|
const columns = await client.query(`
|
|
SELECT table_name AS "tableName", column_name AS "columnName",
|
|
data_type AS "dataType", datetime_precision AS "precision"
|
|
FROM information_schema.columns
|
|
WHERE table_schema = $1
|
|
AND table_name IN ($$read_model_outbox$$, $$web_push_outbox$$)
|
|
AND column_name IN ($$available_at$$, $$locked_at$$, $$delivered_at$$, $$created_at$$)
|
|
ORDER BY table_name, column_name
|
|
`, [process.env.SCHEMA_NAME]);
|
|
if (columns.rowCount !== 8) throw new Error(`expected eight outbox timestamp columns, received ${columns.rowCount}`);
|
|
for (const column of columns.rows) {
|
|
if (column.dataType !== "timestamp without time zone" || column.precision !== 3) {
|
|
throw new Error(`rollback-incompatible timestamp column: ${JSON.stringify(column)}`);
|
|
}
|
|
}
|
|
|
|
const expectedChecksum = createHash("sha256")
|
|
.update(await readFile(process.env.MIGRATION_FILE))
|
|
.digest("hex");
|
|
const history = await client.query(`
|
|
SELECT checksum, finished_at IS NOT NULL AS finished,
|
|
rolled_back_at IS NULL AS "notRolledBack", applied_steps_count AS steps
|
|
FROM "_prisma_migrations"
|
|
WHERE migration_name = $1
|
|
`, [process.env.TARGET_MIGRATION]);
|
|
assertEqual(history.rows, [{ checksum: expectedChecksum, finished: true, notRolledBack: true, steps: 1 }], "migration history");
|
|
|
|
// This is the rollback-compatibility boundary: the predecessor runtime
|
|
// DML shapes and TIMESTAMP(3) mappings remain accepted. It deliberately
|
|
// does not claim a down migration or execute a separately built old binary.
|
|
const rollbackProbe = await client.query(`
|
|
WITH inserted_read AS (
|
|
INSERT INTO "read_model_outbox" ("payload")
|
|
VALUES ($json$ {"version":1,"changes":[],"fixture":"old-read-shape"} $json$::jsonb)
|
|
RETURNING "created_at", "available_at"
|
|
), inserted_web AS (
|
|
INSERT INTO "web_push_outbox" (
|
|
"event_id", "event_type", "user_ids", "available_at", "created_at"
|
|
) VALUES (
|
|
$$upgrade-old-web-shape$$,
|
|
$$PRIVATE_MESSAGE_RECEIVED$$,
|
|
ARRAY[]::TEXT[],
|
|
CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$,
|
|
CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$
|
|
)
|
|
RETURNING "created_at", "available_at"
|
|
)
|
|
SELECT
|
|
inserted_read."created_at" BETWEEN
|
|
(CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) - INTERVAL $$1 second$$ AND
|
|
(CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ AS "readCreatedUtc",
|
|
inserted_read."available_at" BETWEEN
|
|
(CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) - INTERVAL $$1 second$$ AND
|
|
(CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ AS "readAvailableUtc",
|
|
inserted_web."created_at" BETWEEN
|
|
(CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) - INTERVAL $$1 second$$ AND
|
|
(CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ AS "webCreatedUtc",
|
|
inserted_web."available_at" BETWEEN
|
|
(CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) - INTERVAL $$1 second$$ AND
|
|
(CURRENT_TIMESTAMP AT TIME ZONE $$UTC$$) + INTERVAL $$1 second$$ AS "webAvailableUtc"
|
|
FROM inserted_read CROSS JOIN inserted_web
|
|
`);
|
|
assertEqual(
|
|
rollbackProbe.rows,
|
|
[{ readCreatedUtc: true, readAvailableUtc: true, webCreatedUtc: true, webAvailableUtc: true }],
|
|
"predecessor runtime DML compatibility"
|
|
);
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
'
|
|
|
|
if ! DATABASE_URL=$database_url PRISMA_SCHEMA="$prisma_dir/game.prisma" \
|
|
pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" \
|
|
>"$work_dir/noop-deploy.log" 2>&1; then
|
|
echo "second outbox UTC-wall migration deploy failed" >&2
|
|
exit 1
|
|
fi
|
|
grep -Fq 'No pending migrations to apply' "$work_dir/noop-deploy.log"
|
|
|
|
if ! DATABASE_URL=$database_url PRISMA_SCHEMA="$prisma_dir/game.prisma" \
|
|
pnpm exec prisma migrate status --schema "$prisma_dir/game.prisma" \
|
|
>"$work_dir/status.log" 2>&1; then
|
|
echo "outbox UTC-wall migration status is not clean" >&2
|
|
exit 1
|
|
fi
|
|
grep -Fq 'Database schema is up to date' "$work_dir/status.log"
|
|
|
|
SCHEMA_NAME=$schema_name \
|
|
TARGET_MIGRATION=$target_migration \
|
|
MIGRATION_FILE="$prisma_dir/migrations/$target_migration/migration.sql" \
|
|
DATABASE_URL=$database_url \
|
|
pnpm exec node --input-type=module -e '
|
|
import { createHash } from "node:crypto";
|
|
import { readFile } from "node:fs/promises";
|
|
import pg from "pg";
|
|
const client = new pg.Client({ connectionString: process.env.DATABASE_URL });
|
|
await client.connect();
|
|
try {
|
|
const expectedChecksum = createHash("sha256")
|
|
.update(await readFile(process.env.MIGRATION_FILE))
|
|
.digest("hex");
|
|
const result = await client.query(`
|
|
SELECT count(*)::int AS count,
|
|
bool_and(checksum = $2) AS "checksumMatches",
|
|
bool_and(finished_at IS NOT NULL) AS finished,
|
|
bool_and(rolled_back_at IS NULL) AS "notRolledBack",
|
|
sum(applied_steps_count)::int AS steps
|
|
FROM ${`"${process.env.SCHEMA_NAME.replaceAll("\"", "\"\"")}"`}."_prisma_migrations"
|
|
WHERE migration_name = $1
|
|
`, [process.env.TARGET_MIGRATION, expectedChecksum]);
|
|
const row = result.rows[0];
|
|
if (
|
|
row?.count !== 1 ||
|
|
row.checksumMatches !== true ||
|
|
row.finished !== true ||
|
|
row.notRolledBack !== true ||
|
|
row.steps !== 1
|
|
) {
|
|
throw new Error(`unexpected target migration history after no-op deploy: ${JSON.stringify(row)}`);
|
|
}
|
|
} finally {
|
|
await client.end();
|
|
}
|
|
'
|
|
|
|
echo "Game outbox UTC-wall populated upgrade, pending requeue, delivered preservation, checksum, no-op, and predecessor DML compatibility passed"
|