시간 도메인과 정지 중 메시지·베팅 경계 정리
This commit is contained in:
@@ -22,6 +22,7 @@
|
||||
"verify:migration:kakao-talk": "sh scripts/verify-kakao-talk-migration.sh",
|
||||
"verify:migration:npc-selection": "sh scripts/verify-npc-selection-token-migration.sh",
|
||||
"verify:migration:outbox-utc": "sh scripts/verify-game-outbox-utc-wall-migration.sh",
|
||||
"verify:migration:time-domains": "sh scripts/verify-time-domain-migration.sh",
|
||||
"coverage:activate:game": "node scripts/activate-read-model-coverage.mjs",
|
||||
"prisma:db:push:game": "prisma db push --schema prisma/game.prisma",
|
||||
"prisma:db:push:gateway": "prisma db push --schema prisma/gateway.prisma"
|
||||
|
||||
@@ -433,20 +433,50 @@ model MessageReadState {
|
||||
}
|
||||
|
||||
model Message {
|
||||
id Int @id @default(autoincrement())
|
||||
mailbox Int
|
||||
type String
|
||||
src Int
|
||||
dest Int
|
||||
time DateTime
|
||||
timeTick BigInt? @map("time_tick")
|
||||
validUntil DateTime @map("valid_until")
|
||||
validUntilTick BigInt? @map("valid_until_tick")
|
||||
message Json
|
||||
id Int @id @default(autoincrement())
|
||||
mailbox Int
|
||||
type String
|
||||
src Int
|
||||
dest Int
|
||||
/// Legacy game-date projection. Never use this as the wall occurrence authority.
|
||||
time DateTime
|
||||
/// Legacy game-date projection coordinate. New rules use occurredGameTick or MessageAction.
|
||||
timeTick BigInt? @map("time_tick")
|
||||
/// Legacy envelope/action visibility projection retained for rolling compatibility.
|
||||
validUntil DateTime @map("valid_until")
|
||||
/// Legacy action deadline projection retained for rolling compatibility.
|
||||
validUntilTick BigInt? @map("valid_until_tick")
|
||||
createdAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at_wall") @db.Timestamp(3)
|
||||
deleteUntilWall DateTime @default(dbgenerated("((CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '5 minutes')")) @map("delete_until_wall") @db.Timestamp(3)
|
||||
tombstonedAtWall DateTime? @map("tombstoned_at_wall") @db.Timestamp(3)
|
||||
occurredGameTick BigInt? @map("occurred_game_tick")
|
||||
message Json
|
||||
|
||||
action MessageAction?
|
||||
|
||||
@@index([mailbox, type, id])
|
||||
@@index([deleteUntilWall])
|
||||
@@map("message")
|
||||
}
|
||||
|
||||
model MessageAction {
|
||||
messageId Int @id @map("message_id")
|
||||
actionType String @map("action_type") @db.VarChar(64)
|
||||
status String @default("PENDING") @db.VarChar(16)
|
||||
createdGameTick BigInt @map("created_game_tick")
|
||||
expiresGameTick BigInt? @map("expires_game_tick")
|
||||
resolvedGameTick BigInt? @map("resolved_game_tick")
|
||||
clockRevision BigInt @map("clock_revision")
|
||||
deadlineGeneration BigInt @map("deadline_generation")
|
||||
createdAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at_wall") @db.Timestamp(3)
|
||||
updatedAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @updatedAt @map("updated_at_wall") @db.Timestamp(3)
|
||||
|
||||
message Message @relation(fields: [messageId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([status, expiresGameTick])
|
||||
@@map("message_action")
|
||||
}
|
||||
|
||||
model RankData {
|
||||
id Int @id @default(autoincrement())
|
||||
nationId Int @default(0) @map("nation_id")
|
||||
@@ -857,6 +887,26 @@ model InheritanceLog {
|
||||
@@map("inheritance_log")
|
||||
}
|
||||
|
||||
/// WALL_TIME purchase/consume receipt for an inheritance command. The linked
|
||||
/// input_event owns the authoritative GAME clock coordinate and retry state.
|
||||
model InheritanceLedger {
|
||||
id BigInt @id @default(autoincrement())
|
||||
requestId String @unique @map("request_id")
|
||||
userId String @map("user_id")
|
||||
action String
|
||||
cost Float
|
||||
status String @default("APPLIED")
|
||||
requestedAtWall DateTime @map("requested_at_wall") @db.Timestamp(3)
|
||||
consumedAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("consumed_at_wall") @db.Timestamp(3)
|
||||
appliedClockRevision BigInt @map("applied_clock_revision")
|
||||
appliedDeadlineGeneration BigInt @map("applied_deadline_generation")
|
||||
createdAtWall DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at_wall") @db.Timestamp(3)
|
||||
|
||||
@@index([userId, id])
|
||||
@@index([status, id])
|
||||
@@map("inheritance_ledger")
|
||||
}
|
||||
|
||||
model InheritanceResult {
|
||||
id Int @id @default(autoincrement())
|
||||
legacyId Int? @unique @map("legacy_id")
|
||||
@@ -897,19 +947,23 @@ model Auction {
|
||||
}
|
||||
|
||||
model AuctionBid {
|
||||
id Int @id @default(autoincrement())
|
||||
auctionId Int @map("auction_id")
|
||||
generalId Int @map("general_id")
|
||||
amount Int
|
||||
eventId String @map("event_id")
|
||||
eventAt DateTime @map("event_at")
|
||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
id Int @id @default(autoincrement())
|
||||
auctionId Int @map("auction_id")
|
||||
generalId Int @map("general_id")
|
||||
amount Int
|
||||
eventId String @map("event_id")
|
||||
/// Legacy/UI projection of occurredGameTick. Never use as expiry authority.
|
||||
eventAt DateTime @map("event_at")
|
||||
occurredGameTick BigInt @map("occurred_game_tick")
|
||||
requestedAtWall DateTime @map("requested_at_wall") @db.Timestamp(3)
|
||||
meta Json @default(dbgenerated("'{}'::jsonb"))
|
||||
createdAt DateTime @default(dbgenerated("(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')")) @map("created_at") @db.Timestamp(3)
|
||||
|
||||
auction Auction @relation(fields: [auctionId], references: [id], onDelete: Cascade)
|
||||
|
||||
@@index([auctionId, amount])
|
||||
@@index([auctionId, eventAt])
|
||||
@@index([auctionId, occurredGameTick])
|
||||
@@map("auction_bid")
|
||||
}
|
||||
|
||||
|
||||
+160
@@ -0,0 +1,160 @@
|
||||
-- Message envelopes are WALL_TIME. The existing time/valid_until columns are
|
||||
-- retained as rolling-deploy projections while actionable gameplay state moves
|
||||
-- to an explicit GAME_TIME record.
|
||||
ALTER TABLE message
|
||||
ADD COLUMN created_at_wall TIMESTAMP(3),
|
||||
ADD COLUMN delete_until_wall TIMESTAMP(3),
|
||||
ADD COLUMN tombstoned_at_wall TIMESTAMP(3),
|
||||
ADD COLUMN occurred_game_tick BIGINT;
|
||||
|
||||
-- Historical rows predate a trustworthy wall-occurrence field. `time` is the
|
||||
-- only available evidence, so preserve it as the best-effort occurrence while
|
||||
-- ensuring the migration can never reopen an old five-minute delete window.
|
||||
UPDATE message
|
||||
SET created_at_wall = time,
|
||||
delete_until_wall = LEAST(time + INTERVAL '5 minutes', CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
tombstoned_at_wall = CASE
|
||||
WHEN lower(COALESCE(message->'option'->>'invalid', 'false')) IN ('true', '1')
|
||||
THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||
ELSE NULL
|
||||
END,
|
||||
occurred_game_tick = time_tick;
|
||||
|
||||
ALTER TABLE message
|
||||
ALTER COLUMN created_at_wall SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
ALTER COLUMN created_at_wall SET NOT NULL,
|
||||
ALTER COLUMN delete_until_wall SET DEFAULT ((CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '5 minutes'),
|
||||
ALTER COLUMN delete_until_wall SET NOT NULL;
|
||||
|
||||
CREATE INDEX message_mailbox_type_id_idx ON message(mailbox, type, id);
|
||||
CREATE INDEX message_delete_until_wall_idx ON message(delete_until_wall);
|
||||
|
||||
CREATE TABLE message_action (
|
||||
message_id INTEGER PRIMARY KEY REFERENCES message(id) ON DELETE CASCADE,
|
||||
action_type VARCHAR(64) NOT NULL,
|
||||
status VARCHAR(16) NOT NULL DEFAULT 'PENDING',
|
||||
created_game_tick BIGINT NOT NULL,
|
||||
expires_game_tick BIGINT,
|
||||
resolved_game_tick BIGINT,
|
||||
clock_revision BIGINT NOT NULL,
|
||||
deadline_generation BIGINT NOT NULL,
|
||||
created_at_wall TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
updated_at_wall TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
CONSTRAINT message_action_status_check CHECK (status IN ('PENDING', 'RESOLVED', 'CANCELLED')),
|
||||
CONSTRAINT message_action_resolution_check CHECK (
|
||||
(status = 'PENDING' AND resolved_game_tick IS NULL)
|
||||
OR (status <> 'PENDING' AND resolved_game_tick IS NOT NULL)
|
||||
)
|
||||
);
|
||||
|
||||
-- Existing actionable payloads used message ticks as their GAME_TIME
|
||||
-- authority. Backfill once; after this migration message_action is authoritative
|
||||
-- and NULL never changes the clock domain of the rule.
|
||||
INSERT INTO message_action (
|
||||
message_id,
|
||||
action_type,
|
||||
status,
|
||||
created_game_tick,
|
||||
expires_game_tick,
|
||||
resolved_game_tick,
|
||||
clock_revision,
|
||||
deadline_generation
|
||||
)
|
||||
SELECT
|
||||
message.id,
|
||||
message.message->'option'->>'action',
|
||||
CASE
|
||||
WHEN message.time_tick IS NULL
|
||||
OR (message.valid_until < TIMESTAMP '9000-01-01' AND message.valid_until_tick IS NULL)
|
||||
OR lower(COALESCE(message.message->'option'->>'used', 'false')) IN ('true', '1')
|
||||
OR lower(COALESCE(message.message->'option'->>'invalid', 'false')) IN ('true', '1')
|
||||
OR message.valid_until <= message.time
|
||||
THEN 'RESOLVED'
|
||||
ELSE 'PENDING'
|
||||
END,
|
||||
COALESCE(message.time_tick, 0),
|
||||
CASE
|
||||
WHEN message.valid_until_tick IS NULL
|
||||
OR message.valid_until_tick >= 9007199254740991
|
||||
THEN NULL
|
||||
ELSE message.valid_until_tick
|
||||
END,
|
||||
CASE
|
||||
WHEN message.time_tick IS NULL
|
||||
OR (message.valid_until < TIMESTAMP '9000-01-01' AND message.valid_until_tick IS NULL)
|
||||
OR lower(COALESCE(message.message->'option'->>'used', 'false')) IN ('true', '1')
|
||||
OR lower(COALESCE(message.message->'option'->>'invalid', 'false')) IN ('true', '1')
|
||||
OR message.valid_until <= message.time
|
||||
THEN COALESCE(message.valid_until_tick, message.time_tick, 0)
|
||||
ELSE NULL
|
||||
END,
|
||||
COALESCE((SELECT clock_revision FROM world_state ORDER BY id ASC LIMIT 1), 1),
|
||||
COALESCE((SELECT deadline_generation FROM world_state ORDER BY id ASC LIMIT 1), 1)
|
||||
FROM message
|
||||
WHERE jsonb_typeof(message.message->'option') = 'object'
|
||||
AND NULLIF(message.message->'option'->>'action', '') IS NOT NULL;
|
||||
|
||||
CREATE INDEX message_action_status_expires_game_tick_idx
|
||||
ON message_action(status, expires_game_tick);
|
||||
|
||||
-- Inheritance requests are WALL_TIME receipts. Their input_event row remains
|
||||
-- the durable command/effect state and owns the GAME clock fence coordinate.
|
||||
CREATE TABLE inheritance_ledger (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
request_id TEXT NOT NULL UNIQUE,
|
||||
user_id TEXT NOT NULL,
|
||||
action TEXT NOT NULL,
|
||||
cost DOUBLE PRECISION NOT NULL,
|
||||
status TEXT NOT NULL DEFAULT 'APPLIED',
|
||||
requested_at_wall TIMESTAMP(3) NOT NULL,
|
||||
consumed_at_wall TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
applied_clock_revision BIGINT NOT NULL,
|
||||
applied_deadline_generation BIGINT NOT NULL,
|
||||
created_at_wall TIMESTAMP(3) NOT NULL DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC'),
|
||||
CONSTRAINT inheritance_ledger_status_check CHECK (status IN ('APPLIED')),
|
||||
CONSTRAINT inheritance_ledger_cost_check CHECK (cost >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX inheritance_ledger_user_id_id_idx ON inheritance_ledger(user_id, id);
|
||||
CREATE INDEX inheritance_ledger_status_id_idx ON inheritance_ledger(status, id);
|
||||
|
||||
-- Auction bid receipt and gameplay occurrence are different facts. event_at is
|
||||
-- retained as the GAME_TIME projection used by existing UI and ordering code.
|
||||
ALTER TABLE auction_bid
|
||||
ADD COLUMN requested_at_wall TIMESTAMP(3),
|
||||
ADD COLUMN occurred_game_tick BIGINT;
|
||||
|
||||
UPDATE auction_bid AS bid
|
||||
SET requested_at_wall = bid.created_at,
|
||||
occurred_game_tick = ROUND(
|
||||
EXTRACT(EPOCH FROM (bid.event_at - world.clock_base_time))
|
||||
* (36000000::numeric / world.tick_seconds)
|
||||
)::bigint
|
||||
FROM world_state AS world;
|
||||
|
||||
ALTER TABLE auction_bid
|
||||
ALTER COLUMN requested_at_wall SET NOT NULL,
|
||||
ALTER COLUMN occurred_game_tick SET NOT NULL,
|
||||
ALTER COLUMN created_at SET DEFAULT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC');
|
||||
|
||||
CREATE INDEX auction_bid_auction_occurred_game_tick_idx
|
||||
ON auction_bid(auction_id, occurred_game_tick);
|
||||
|
||||
-- Selection-pool reselection is expressed in turns. Preserve the old DateTime
|
||||
-- keys only as projections and make one GAME_TIME authority explicit.
|
||||
UPDATE general AS actor
|
||||
SET meta = jsonb_set(
|
||||
actor.meta,
|
||||
'{next_change_tick}',
|
||||
to_jsonb(ROUND(
|
||||
EXTRACT(EPOCH FROM (
|
||||
COALESCE(NULLIF(actor.meta->>'next_change', ''), actor.meta->>'nextChangeAt')::timestamp
|
||||
- world.clock_base_time
|
||||
)) * (36000000::numeric / world.tick_seconds)
|
||||
)::bigint),
|
||||
true
|
||||
)
|
||||
FROM world_state AS world
|
||||
WHERE COALESCE(NULLIF(actor.meta->>'next_change', ''), actor.meta->>'nextChangeAt') IS NOT NULL
|
||||
AND COALESCE(NULLIF(actor.meta->>'next_change', ''), actor.meta->>'nextChangeAt')
|
||||
~ '^\d{4}-\d{2}-\d{2}T';
|
||||
@@ -48,6 +48,16 @@ chain을 적용하고 두 번째 실행은 `No pending migrations to apply`여
|
||||
- `select_npc_token`, `select_npc_token_valid_until_idx`
|
||||
- `general_user_id_key`
|
||||
|
||||
## 시간 도메인 populated upgrade 검증
|
||||
|
||||
메시지 envelope의 WALL_TIME, actionable message와 선택 cooldown·경매의
|
||||
GAME_TIME backfill, 유산 receipt table, 두 번째 deploy no-op을 전용 tmpfs
|
||||
PostgreSQL에서 검증합니다. 영속 Docker volume은 만들지 않습니다.
|
||||
|
||||
```sh
|
||||
pnpm --filter @sammo-ts/infra verify:migration:time-domains
|
||||
```
|
||||
|
||||
검증이 끝나면 이름을 직접 확인한 임시 database와 role만 제거합니다. 공유
|
||||
database나 Compose volume을 삭제하지 않습니다.
|
||||
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
#!/bin/sh
|
||||
set -eu
|
||||
|
||||
script_dir="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
||||
package_dir="$(dirname "$script_dir")"
|
||||
prisma_dir="$package_dir/prisma"
|
||||
target_migration=20260903140000_split_message_wall_and_game_time
|
||||
task_label=devsam.core2026.time-domain-migration-preflight
|
||||
run_id="$(date -u +%m%d%H%M%S)_$$"
|
||||
container_name="sammo-time-domain-preflight-$run_id"
|
||||
schema_name="time_domain_preflight_$run_id"
|
||||
work_dir="$(mktemp -d /tmp/sammo-time-domain-preflight.XXXXXX)"
|
||||
container_created=0
|
||||
|
||||
case "$container_name" in sammo-time-domain-preflight-[0-9]*_[0-9]*) ;; *) exit 64 ;; esac
|
||||
case "$schema_name" in time_domain_preflight_[0-9]*_[0-9]*) ;; *) exit 64 ;; esac
|
||||
|
||||
cleanup() {
|
||||
cleanup_failed=0
|
||||
if [ "$container_created" -eq 1 ] && docker inspect "$container_name" >/dev/null 2>&1; then
|
||||
actual_label="$(docker inspect --format '{{ index .Config.Labels "devsam.core2026.task" }}' "$container_name")"
|
||||
if [ "$actual_label" != "$task_label" ]; then
|
||||
echo "refusing to remove container with unexpected ownership label" >&2
|
||||
cleanup_failed=1
|
||||
elif ! docker rm -f "$container_name" >/dev/null; then
|
||||
cleanup_failed=1
|
||||
fi
|
||||
fi
|
||||
case "$work_dir" in
|
||||
/tmp/sammo-time-domain-preflight.*) rm -r -- "$work_dir" || cleanup_failed=1 ;;
|
||||
*) cleanup_failed=1 ;;
|
||||
esac
|
||||
return "$cleanup_failed"
|
||||
}
|
||||
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
|
||||
|
||||
command -v docker >/dev/null 2>&1 || { echo "docker is required" >&2; exit 69; }
|
||||
[ -d "$prisma_dir/migrations/$target_migration" ] || { echo "target migration is missing" >&2; exit 66; }
|
||||
|
||||
umask 077
|
||||
password="$(od -An -N24 -tx1 /dev/urandom | tr -d ' \n')"
|
||||
password_file="$work_dir/postgres_password"
|
||||
printf '%s\n' "$password" >"$password_file"
|
||||
|
||||
docker run -d \
|
||||
--name "$container_name" \
|
||||
--label "devsam.core2026.task=$task_label" \
|
||||
--tmpfs /var/lib/postgresql:rw,nodev,nosuid,size=1g \
|
||||
--mount "type=bind,source=$password_file,target=/run/secrets/postgres_password,readonly" \
|
||||
-e POSTGRES_DB=sammo \
|
||||
-e POSTGRES_USER=sammo \
|
||||
-e POSTGRES_PASSWORD_FILE=/run/secrets/postgres_password \
|
||||
-p 127.0.0.1::5432 \
|
||||
postgres:18.4-bookworm >/dev/null
|
||||
container_created=1
|
||||
|
||||
if [ -n "$(docker inspect --format '{{ range .Mounts }}{{ if eq .Type "volume" }}volume{{ end }}{{ end }}' "$container_name")" ]; then
|
||||
echo "preflight container unexpectedly owns a Docker volume" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
attempt=0
|
||||
until docker exec "$container_name" pg_isready -U sammo -d sammo >/dev/null 2>&1; do
|
||||
attempt=$((attempt + 1))
|
||||
if [ "$attempt" -ge 60 ]; then docker logs --tail 100 "$container_name" >&2; exit 1; fi
|
||||
sleep 1
|
||||
done
|
||||
|
||||
published_port="$(docker port "$container_name" 5432/tcp)"
|
||||
published_port="${published_port##*:}"
|
||||
case "$published_port" in ''|*[!0-9]*) exit 1 ;; esac
|
||||
|
||||
export POSTGRES_HOST=127.0.0.1
|
||||
export POSTGRES_PORT="$published_port"
|
||||
export POSTGRES_DB=sammo
|
||||
export POSTGRES_USER=sammo
|
||||
export POSTGRES_PASSWORD="$password"
|
||||
export POSTGRES_SCHEMA="$schema_name"
|
||||
unset DATABASE_URL DATABASE_SCHEMA
|
||||
|
||||
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 ] || exit 1
|
||||
|
||||
cd "$package_dir"
|
||||
PRISMA_SCHEMA="$stage_prisma/game.prisma" \
|
||||
pnpm exec prisma migrate deploy --schema "$stage_prisma/game.prisma" >"$work_dir/predecessor.log"
|
||||
|
||||
docker exec -i "$container_name" psql -v ON_ERROR_STOP=1 -U sammo -d sammo >/dev/null <<SQL
|
||||
SET search_path TO "$schema_name";
|
||||
INSERT INTO world_state (
|
||||
scenario_code, current_year, current_month, tick_seconds,
|
||||
clock_base_time, clock_tick, clock_wall_anchor, last_turn_tick, updated_at
|
||||
) VALUES (
|
||||
'time-domain-fixture', 200, 1, 600,
|
||||
TIMESTAMP '0200-01-01 00:00:00', 36000000, TIMESTAMP '2026-09-03 00:00:00', 36000000,
|
||||
TIMESTAMP '2026-09-03 00:00:00'
|
||||
);
|
||||
INSERT INTO general (id, name, turn_time, meta)
|
||||
VALUES (
|
||||
910001,
|
||||
'시간장수',
|
||||
TIMESTAMP '0200-01-01 00:10:00',
|
||||
jsonb_build_object(
|
||||
'next_change', '0200-01-01T00:30:00.000Z',
|
||||
'nextChangeAt', '0200-01-01T00:30:00.000Z'
|
||||
)
|
||||
);
|
||||
INSERT INTO message (id, mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message)
|
||||
VALUES
|
||||
(920001, 0, 'global', 1, 0, TIMESTAMP '0200-01-01 00:00:00', 36000000,
|
||||
TIMESTAMP '9999-12-31 00:00:00', 9007199254740991,
|
||||
jsonb_build_object('text', 'normal')),
|
||||
(920002, 1, 'private', 1, 2, TIMESTAMP '0200-01-01 00:05:00', 54000000,
|
||||
TIMESTAMP '0200-01-01 01:00:00', 252000000,
|
||||
jsonb_build_object('option', jsonb_build_object('action', 'raiseInvader', 'used', false))),
|
||||
(920003, 2, 'private', 1, 2, TIMESTAMP '0200-01-01 00:06:00', NULL,
|
||||
TIMESTAMP '0200-01-01 01:00:00', NULL,
|
||||
jsonb_build_object('option', jsonb_build_object('action', 'scout', 'used', false)));
|
||||
INSERT INTO auction (id, type, host_general_id, status, close_at, open_tick, close_tick)
|
||||
VALUES (930001, 'UNIQUE_ITEM', 910001, 'OPEN', TIMESTAMP '0200-01-01 01:00:00', 36000000, 252000000);
|
||||
INSERT INTO auction_bid (id, auction_id, general_id, amount, event_id, event_at, created_at)
|
||||
VALUES (930002, 930001, 910001, 100, 'time-domain-bid', TIMESTAMP '0200-01-01 00:10:00',
|
||||
TIMESTAMP '2026-09-03 01:02:03.456');
|
||||
SQL
|
||||
|
||||
PRISMA_SCHEMA="$prisma_dir/game.prisma" \
|
||||
pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" >"$work_dir/target.log"
|
||||
PRISMA_SCHEMA="$prisma_dir/game.prisma" \
|
||||
pnpm exec prisma migrate deploy --schema "$prisma_dir/game.prisma" >"$work_dir/noop.log"
|
||||
grep -Fq 'No pending migrations to apply' "$work_dir/noop.log"
|
||||
|
||||
result="$(docker exec "$container_name" psql -v ON_ERROR_STOP=1 -U sammo -d sammo -tAc "
|
||||
SET search_path TO \"$schema_name\";
|
||||
SELECT
|
||||
(SELECT count(*) FROM message_action) = 2
|
||||
AND (SELECT action_type = 'raiseInvader' AND status = 'PENDING' AND expires_game_tick = 252000000
|
||||
FROM message_action WHERE message_id = 920002)
|
||||
AND (SELECT status = 'RESOLVED' AND resolved_game_tick = 0
|
||||
FROM message_action WHERE message_id = 920003)
|
||||
AND (SELECT requested_at_wall = TIMESTAMP '2026-09-03 01:02:03.456'
|
||||
AND occurred_game_tick = 36000000
|
||||
FROM auction_bid WHERE id = 930002)
|
||||
AND (SELECT (meta->>'next_change_tick')::bigint = 108000000 FROM general WHERE id = 910001)
|
||||
AND (SELECT delete_until_wall <= CURRENT_TIMESTAMP AT TIME ZONE 'UTC' FROM message WHERE id = 920001)
|
||||
AND to_regclass('\"$schema_name\".inheritance_ledger') IS NOT NULL;
|
||||
" | tail -n 1)"
|
||||
[ "$result" = "t" ] || { echo "time-domain migration assertions failed: $result" >&2; exit 1; }
|
||||
|
||||
echo "time-domain populated migration and no-op redeploy passed"
|
||||
@@ -13,6 +13,7 @@ export interface DatabaseClient {
|
||||
trafficPeriodGeneral: GamePrisma.TrafficPeriodGeneralDelegate;
|
||||
messageReadState: GamePrisma.MessageReadStateDelegate;
|
||||
message: GamePrisma.MessageDelegate;
|
||||
messageAction: GamePrisma.MessageActionDelegate;
|
||||
city: GamePrisma.CityDelegate;
|
||||
nation: GamePrisma.NationDelegate;
|
||||
diplomacy: GamePrisma.DiplomacyDelegate;
|
||||
@@ -38,6 +39,7 @@ export interface DatabaseClient {
|
||||
nationBetting: GamePrisma.NationBettingDelegate;
|
||||
nationBet: GamePrisma.NationBetDelegate;
|
||||
inheritanceLog: GamePrisma.InheritanceLogDelegate;
|
||||
inheritanceLedger: GamePrisma.InheritanceLedgerDelegate;
|
||||
inheritanceResult: GamePrisma.InheritanceResultDelegate;
|
||||
inheritanceUserState: GamePrisma.InheritanceUserStateDelegate;
|
||||
boardPost: GamePrisma.BoardPostDelegate;
|
||||
|
||||
@@ -11,4 +11,5 @@ export * from './readModelOutboxDispatcher.js';
|
||||
export * from './readModelCoverageActivation.js';
|
||||
export * from './gameSchemaAdvisoryLock.js';
|
||||
export * from './inputEventClock.js';
|
||||
export * from './messageEnvelope.js';
|
||||
export * from './webPushOutbox.js';
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { MessageRecordDraft } from '@sammo-ts/logic';
|
||||
|
||||
import { GamePrisma } from './gamePrisma.js';
|
||||
|
||||
export interface MessageGameContext {
|
||||
occurredGameTick: bigint;
|
||||
clockRevision: bigint;
|
||||
deadlineGeneration: bigint;
|
||||
expiresGameTick: bigint | null;
|
||||
}
|
||||
|
||||
export type MessageEnvelopeDatabase = Pick<GamePrisma.TransactionClient, '$queryRaw'>;
|
||||
|
||||
const resolveActionType = (draft: MessageRecordDraft): string | null => {
|
||||
const option = draft.payload.option;
|
||||
if (!option || typeof option !== 'object' || Array.isArray(option)) return null;
|
||||
const action = Reflect.get(option, 'action');
|
||||
return typeof action === 'string' && action.trim() !== '' ? action : null;
|
||||
};
|
||||
|
||||
/**
|
||||
* Persists a WALL_TIME message envelope and, only for an explicit actionable
|
||||
* payload, a separate GAME_TIME action row. PostgreSQL supplies the envelope
|
||||
* occurrence and delete deadline; caller clocks are compatibility projections.
|
||||
*/
|
||||
export const persistMessageEnvelope = async (
|
||||
db: MessageEnvelopeDatabase,
|
||||
draft: MessageRecordDraft,
|
||||
gameContext: MessageGameContext | null = null
|
||||
): Promise<number> => {
|
||||
const actionType = resolveActionType(draft);
|
||||
if (actionType !== null && gameContext === null) {
|
||||
throw new Error(`Actionable message ${actionType} requires an authoritative game clock context.`);
|
||||
}
|
||||
|
||||
const occurredGameTick = gameContext?.occurredGameTick ?? null;
|
||||
const legacyValidUntilTick = actionType === null ? null : gameContext!.expiresGameTick;
|
||||
const rows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||
WITH wall AS (
|
||||
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall
|
||||
), inserted AS (
|
||||
INSERT INTO message (
|
||||
mailbox,
|
||||
type,
|
||||
src,
|
||||
dest,
|
||||
time,
|
||||
time_tick,
|
||||
valid_until,
|
||||
valid_until_tick,
|
||||
created_at_wall,
|
||||
delete_until_wall,
|
||||
occurred_game_tick,
|
||||
message
|
||||
)
|
||||
SELECT
|
||||
${draft.mailbox},
|
||||
${draft.msgType},
|
||||
${draft.srcId},
|
||||
${draft.destId},
|
||||
${draft.time},
|
||||
${occurredGameTick},
|
||||
${draft.validUntil},
|
||||
${legacyValidUntilTick},
|
||||
wall.now_wall,
|
||||
wall.now_wall + INTERVAL '5 minutes',
|
||||
${occurredGameTick},
|
||||
CAST(${JSON.stringify(draft.payload)} AS jsonb)
|
||||
FROM wall
|
||||
RETURNING id
|
||||
), action AS (
|
||||
INSERT INTO message_action (
|
||||
message_id,
|
||||
action_type,
|
||||
status,
|
||||
created_game_tick,
|
||||
expires_game_tick,
|
||||
clock_revision,
|
||||
deadline_generation
|
||||
)
|
||||
SELECT
|
||||
inserted.id,
|
||||
${actionType},
|
||||
'PENDING',
|
||||
${gameContext?.occurredGameTick ?? 0n},
|
||||
${gameContext?.expiresGameTick ?? null},
|
||||
${gameContext?.clockRevision ?? 0n},
|
||||
${gameContext?.deadlineGeneration ?? 0n}
|
||||
FROM inserted
|
||||
WHERE ${actionType} IS NOT NULL
|
||||
RETURNING message_id
|
||||
)
|
||||
SELECT id FROM inserted
|
||||
`);
|
||||
const id = rows[0]?.id;
|
||||
if (!id) throw new Error('Failed to persist message envelope.');
|
||||
return id;
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { parseReadModelOutboxPayload, type ReadModelOutboxPayloadV1 } from '@sam
|
||||
|
||||
import { GamePrisma, type GamePrismaClient } from './gamePrisma.js';
|
||||
|
||||
export interface ReadModelOutboxDatabase extends Pick<GamePrismaClient, '$queryRaw'> {
|
||||
export interface ReadModelOutboxDatabase extends Pick<GamePrismaClient, '$queryRaw' | '$executeRaw'> {
|
||||
readModelOutbox: GamePrisma.ReadModelOutboxDelegate;
|
||||
}
|
||||
|
||||
@@ -58,15 +58,16 @@ export const claimReadModelOutboxBatch = async (
|
||||
}
|
||||
const limit = normalizeLimit(options.limit);
|
||||
const leaseMs = normalizeDuration(options.leaseMs, 30_000);
|
||||
const now = options.now ?? new Date();
|
||||
const leaseExpiredBefore = new Date(now.getTime() - leaseMs);
|
||||
const nowSql = options.now
|
||||
? GamePrisma.sql`${options.now}`
|
||||
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
|
||||
const rows = await db.$queryRaw<ClaimedRow[]>(GamePrisma.sql`
|
||||
WITH candidates AS (
|
||||
SELECT "id"
|
||||
FROM "read_model_outbox"
|
||||
WHERE "delivered_at" IS NULL
|
||||
AND "available_at" <= ${now}
|
||||
AND ("locked_at" IS NULL OR "locked_at" < ${leaseExpiredBefore})
|
||||
AND "available_at" <= ${nowSql}
|
||||
AND ("locked_at" IS NULL OR "locked_at" < ${nowSql} - ${leaseMs} * INTERVAL '1 millisecond')
|
||||
ORDER BY "id"
|
||||
LIMIT ${limit}
|
||||
FOR UPDATE SKIP LOCKED
|
||||
@@ -74,7 +75,7 @@ export const claimReadModelOutboxBatch = async (
|
||||
UPDATE "read_model_outbox" AS outbox
|
||||
SET
|
||||
"attempts" = outbox."attempts" + 1,
|
||||
"locked_at" = ${now},
|
||||
"locked_at" = ${nowSql},
|
||||
"lock_owner" = ${options.owner},
|
||||
"last_error" = NULL
|
||||
FROM candidates
|
||||
@@ -89,32 +90,43 @@ export const markReadModelOutboxDelivered = async (
|
||||
db: ReadModelOutboxDatabase,
|
||||
input: { id: bigint; owner: string; deliveredAt?: Date }
|
||||
): Promise<boolean> => {
|
||||
const result = await db.readModelOutbox.updateMany({
|
||||
where: { id: input.id, lockOwner: input.owner, deliveredAt: null },
|
||||
data: {
|
||||
deliveredAt: input.deliveredAt ?? new Date(),
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
lastError: null,
|
||||
},
|
||||
});
|
||||
return result.count === 1;
|
||||
const deliveredAtSql = input.deliveredAt
|
||||
? GamePrisma.sql`${input.deliveredAt}`
|
||||
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
|
||||
return (
|
||||
(await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE "read_model_outbox"
|
||||
SET "delivered_at" = ${deliveredAtSql},
|
||||
"locked_at" = NULL,
|
||||
"lock_owner" = NULL,
|
||||
"last_error" = NULL
|
||||
WHERE "id" = ${input.id}
|
||||
AND "lock_owner" = ${input.owner}
|
||||
AND "delivered_at" IS NULL
|
||||
`)) === 1
|
||||
);
|
||||
};
|
||||
|
||||
export const releaseReadModelOutbox = async (
|
||||
db: ReadModelOutboxDatabase,
|
||||
input: { id: bigint; owner: string; error: unknown; availableAt: Date }
|
||||
input: { id: bigint; owner: string; error: unknown; availableAt?: Date; availableAfterMs?: number }
|
||||
): Promise<boolean> => {
|
||||
const result = await db.readModelOutbox.updateMany({
|
||||
where: { id: input.id, lockOwner: input.owner, deliveredAt: null },
|
||||
data: {
|
||||
availableAt: input.availableAt,
|
||||
lockedAt: null,
|
||||
lockOwner: null,
|
||||
lastError: formatDispatchError(input.error),
|
||||
},
|
||||
});
|
||||
return result.count === 1;
|
||||
const availableAtSql = input.availableAt
|
||||
? GamePrisma.sql`${input.availableAt}`
|
||||
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
||||
+ ${normalizeDuration(input.availableAfterMs, 1_000)} * INTERVAL '1 millisecond'`;
|
||||
return (
|
||||
(await db.$executeRaw(GamePrisma.sql`
|
||||
UPDATE "read_model_outbox"
|
||||
SET "available_at" = ${availableAtSql},
|
||||
"locked_at" = NULL,
|
||||
"lock_owner" = NULL,
|
||||
"last_error" = ${formatDispatchError(input.error)}
|
||||
WHERE "id" = ${input.id}
|
||||
AND "lock_owner" = ${input.owner}
|
||||
AND "delivered_at" IS NULL
|
||||
`)) === 1
|
||||
);
|
||||
};
|
||||
|
||||
export const dispatchReadModelOutboxBatch = async (
|
||||
@@ -122,14 +134,14 @@ export const dispatchReadModelOutboxBatch = async (
|
||||
publish: (payload: ReadModelOutboxPayloadV1, outboxId: bigint) => Promise<void>,
|
||||
options: ReadModelOutboxDispatchOptions
|
||||
): Promise<ReadModelOutboxDispatchResult> => {
|
||||
const now = options.now ?? (() => new Date());
|
||||
const testNow = options.now;
|
||||
const retryBaseMs = normalizeDuration(options.retryBaseMs, 1_000);
|
||||
const retryMaxMs = Math.max(retryBaseMs, normalizeDuration(options.retryMaxMs, 60_000));
|
||||
const claimed = await claimReadModelOutboxBatch(db, {
|
||||
owner: options.owner,
|
||||
limit: options.limit,
|
||||
leaseMs: options.leaseMs,
|
||||
now: now(),
|
||||
...(testNow ? { now: testNow() } : {}),
|
||||
});
|
||||
let delivered = 0;
|
||||
let failed = 0;
|
||||
@@ -141,7 +153,13 @@ export const dispatchReadModelOutboxBatch = async (
|
||||
throw new Error(`Read-model outbox ${item.id.toString()} has an invalid payload.`);
|
||||
}
|
||||
await publish(payload, item.id);
|
||||
if (!(await markReadModelOutboxDelivered(db, { id: item.id, owner: options.owner, deliveredAt: now() }))) {
|
||||
if (
|
||||
!(await markReadModelOutboxDelivered(db, {
|
||||
id: item.id,
|
||||
owner: options.owner,
|
||||
...(testNow ? { deliveredAt: testNow() } : {}),
|
||||
}))
|
||||
) {
|
||||
throw new Error(`Read-model outbox ${item.id.toString()} lost its delivery lease.`);
|
||||
}
|
||||
delivered += 1;
|
||||
@@ -151,7 +169,9 @@ export const dispatchReadModelOutboxBatch = async (
|
||||
id: item.id,
|
||||
owner: options.owner,
|
||||
error,
|
||||
availableAt: new Date(now().getTime() + retryDelayMs(item.attempts, retryBaseMs, retryMaxMs)),
|
||||
...(testNow
|
||||
? { availableAt: new Date(testNow().getTime() + retryDelayMs(item.attempts, retryBaseMs, retryMaxMs)) }
|
||||
: { availableAfterMs: retryDelayMs(item.attempts, retryBaseMs, retryMaxMs) }),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,9 +15,11 @@ const validPayload = {
|
||||
const createDb = (rows: readonly object[]) => {
|
||||
const queryRaw = vi.fn().mockResolvedValue(rows);
|
||||
const updateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||
const executeRaw = vi.fn().mockResolvedValue(1);
|
||||
return {
|
||||
db: { $queryRaw: queryRaw, readModelOutbox: { updateMany } } as unknown as GamePrismaClient,
|
||||
db: { $queryRaw: queryRaw, $executeRaw: executeRaw, readModelOutbox: { updateMany } } as unknown as GamePrismaClient,
|
||||
queryRaw,
|
||||
executeRaw,
|
||||
updateMany,
|
||||
};
|
||||
};
|
||||
@@ -46,12 +48,7 @@ describe('read-model outbox dispatcher', () => {
|
||||
|
||||
expect(result).toEqual({ claimed: 1, delivered: 1, failed: 0 });
|
||||
expect(publish).toHaveBeenCalledWith(validPayload, 1n);
|
||||
expect(fixture.updateMany).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
where: { id: 1n, lockOwner: 'worker-a', deliveredAt: null },
|
||||
data: expect.objectContaining({ deliveredAt: new Date('2026-08-16T00:00:00.000Z') }),
|
||||
})
|
||||
);
|
||||
expect(fixture.executeRaw).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it('releases failed and malformed rows with bounded retry state', async () => {
|
||||
@@ -69,20 +66,7 @@ describe('read-model outbox dispatcher', () => {
|
||||
|
||||
expect(result).toEqual({ claimed: 2, delivered: 0, failed: 2 });
|
||||
expect(publish).toHaveBeenCalledTimes(1);
|
||||
expect(fixture.updateMany).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({
|
||||
where: { id: 1n, lockOwner: 'worker-a', deliveredAt: null },
|
||||
data: expect.objectContaining({ availableAt: new Date('2026-08-16T00:00:04.000Z') }),
|
||||
})
|
||||
);
|
||||
expect(fixture.updateMany).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({
|
||||
where: { id: 2n, lockOwner: 'worker-a', deliveredAt: null },
|
||||
data: expect.objectContaining({ availableAt: new Date('2026-08-16T00:00:01.000Z') }),
|
||||
})
|
||||
);
|
||||
expect(fixture.executeRaw).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('prunes only a bounded delivered batch', async () => {
|
||||
|
||||
Reference in New Issue
Block a user