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

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
+27 -3
View File
@@ -9,13 +9,18 @@ declare const gameTickBrand: unique symbol;
declare const observedGameInstantBrand: unique symbol;
declare const scheduleInstantBrand: unique symbol;
declare const clockRevisionBrand: unique symbol;
declare const deadlineGenerationBrand: unique symbol;
declare const wallInstantBrand: unique symbol;
declare const monotonicDurationBrand: unique symbol;
export type GameTick = number & { readonly [gameTickBrand]: 'GameTick' };
export type ObservedGameInstant = GameTick & { readonly [observedGameInstantBrand]: 'ObservedGameInstant' };
export type ScheduleInstant = GameTick & { readonly [scheduleInstantBrand]: 'ScheduleInstant' };
export type ClockRevision = number & { readonly [clockRevisionBrand]: 'ClockRevision' };
export type DeadlineGeneration = number & { readonly [deadlineGenerationBrand]: 'DeadlineGeneration' };
export type WallInstant = Date & { readonly [wallInstantBrand]: 'WallInstant' };
/** Process-local elapsed milliseconds; never persist this value as a business timestamp. */
export type MonotonicDuration = number & { readonly [monotonicDurationBrand]: 'MonotonicDuration' };
export interface ClockAlignmentPlan {
policy: ClockAlignmentPolicy;
@@ -59,6 +64,13 @@ export const asClockRevision = (revision: number): ClockRevision => {
return revision as ClockRevision;
};
export const asDeadlineGeneration = (generation: number): DeadlineGeneration => {
if (!Number.isSafeInteger(generation) || generation < 1) {
throw new Error(`Deadline generation must be a positive safe integer: ${generation}`);
}
return generation as DeadlineGeneration;
};
export const asWallInstant = (instant: Date): WallInstant => {
if (Number.isNaN(instant.getTime())) {
throw new Error('Wall instant must be a valid date.');
@@ -66,6 +78,13 @@ export const asWallInstant = (instant: Date): WallInstant => {
return new Date(instant.getTime()) as WallInstant;
};
export const asMonotonicDuration = (milliseconds: number): MonotonicDuration => {
if (!Number.isFinite(milliseconds) || milliseconds < 0) {
throw new Error(`Monotonic duration must be a non-negative finite number: ${milliseconds}`);
}
return milliseconds as MonotonicDuration;
};
export const inferClockPhase = (mode: GameClockMode): GameClockPhase => (mode === 'manual' ? 'MANUAL' : 'RUNNING');
const GAME_CLOCK_PHASES: readonly GameClockPhase[] = [
@@ -136,8 +155,7 @@ const buildAlignmentPlan = (input: {
const remainingMilliseconds = elapsedMilliseconds - wholeSeconds * 1_000;
const gapTicks = asGameTick(
requireSafeTick(
wholeSeconds * input.ticksPerSecond +
Math.trunc((remainingMilliseconds * input.ticksPerSecond) / 1_000)
wholeSeconds * input.ticksPerSecond + Math.trunc((remainingMilliseconds * input.ticksPerSecond) / 1_000)
)
);
const catchUpTicks = asGameTick(input.catchUpTicks ?? 0);
@@ -250,7 +268,13 @@ export class GameClock {
}
nowTick(wallNow: Date): number {
if (this.mode === 'manual' || this.phase === 'MANUAL' || this.phase === 'COMPLETED') {
if (
this.mode === 'manual' ||
this.phase === 'MANUAL' ||
this.phase === 'SUSPENDED' ||
this.phase === 'RECONCILING' ||
this.phase === 'COMPLETED'
) {
return this.tick;
}
const elapsedTicks = this.ticksBetween(this.wallAnchor, wallNow);
@@ -24,7 +24,7 @@ export interface TournamentClockFence {
phaseKey: string;
revision: number;
deadlineGeneration: number;
phase: 'RUNNING';
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
}
const WRITE_TOURNAMENT_PROJECTION_SCRIPT = `
+3 -10
View File
@@ -190,7 +190,7 @@ export type TurnDaemonCommand =
requestId?: string;
auctionId: number;
expectedCloseAt?: string;
expectedCloseTick?: number;
expectedCloseTick: number;
}
| {
type: 'auctionOpen';
@@ -260,7 +260,6 @@ export type TurnDaemonCommand =
voteId: number;
generalId: number;
selection: number[];
acceptedGameTick?: number;
}
| {
type: 'setNationSetting';
@@ -386,15 +385,12 @@ export type TurnDaemonCommand =
ownerLegacyPenalty?: Record<string, unknown>;
generalId: number;
tokenNonce: number;
acceptedGameAt?: string;
}
| {
type: 'selectPoolReserve';
requestId?: string;
userId: string;
seedOwnerIdentity: string | number;
acceptedGameAt: string;
acceptedGameTick?: number;
}
| {
type: 'selectPoolCreate';
@@ -407,8 +403,6 @@ export type TurnDaemonCommand =
ownerPicture?: string;
ownerImageServer?: number;
ownerIconRevision?: string;
acceptedGameAt?: string;
acceptedGameTick?: number;
}
| {
type: 'selectPoolReselect';
@@ -416,8 +410,6 @@ export type TurnDaemonCommand =
userId: string;
ownerDisplayName: string;
uniqueName: string;
acceptedGameAt?: string;
acceptedGameTick?: number;
}
| {
type: 'auctionBid';
@@ -426,7 +418,6 @@ export type TurnDaemonCommand =
auctionId: number;
generalId: number;
amount: number;
acceptedGameTick?: number;
tryExtendCloseDate?: boolean;
};
@@ -505,6 +496,7 @@ export type TurnDaemonCommandResult =
ok: true;
auctionId: number;
closeAt: string;
closeTick: number;
}
| {
type: 'auctionOpen';
@@ -835,6 +827,7 @@ export type TurnDaemonCommandResult =
ok: true;
auctionId: number;
closeAt: string;
closeTick: number;
}
| {
type: 'auctionBid';
+18
View File
@@ -13,6 +13,24 @@ import {
} from '../src/time/GameClock.js';
describe('GameClock', () => {
it.each(['SUSPENDED', 'RECONCILING'] as const)(
'keeps the authoritative tick frozen across 24 wall hours while %s',
(phase) => {
const clock = new GameClock({
baseTime: new Date('0200-01-01T00:00:00.000Z'),
tick: 12_345,
mode: 'realtime',
wallAnchor: new Date('2026-09-03T15:00:00.000Z'),
turnSeconds: 600,
phase,
revision: 7,
});
expect(clock.nowTick(new Date('2026-09-04T15:00:00.000Z'))).toBe(12_345);
expect(clock.now(new Date('2026-09-04T15:00:00.000Z'))).toEqual(clock.tickToDate(12_345));
}
);
const baseTime = new Date('2042-01-01T00:00:00.000Z');
it('projects the fixed Ref turn tick and ignores wall time in manual mode', () => {
+1
View File
@@ -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"
+72 -18
View File
@@ -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")
}
@@ -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"
+2
View File
@@ -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;
+1
View File
@@ -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';
+98
View File
@@ -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;
};
+51 -31
View File
@@ -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 () => {