시간 도메인과 정지 중 메시지·베팅 경계 정리
This commit is contained in:
@@ -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을 삭제하지 않습니다.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user