시간 도메인과 정지 중 메시지·베팅 경계 정리

This commit is contained in:
2026-09-03 16:01:19 +00:00
parent 10cddbb565
commit abceee8315
108 changed files with 3504 additions and 1473 deletions
@@ -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을 삭제하지 않습니다.