perf: sync protocol and core hardening updates

This commit is contained in:
A 2026-07-11 19:48:26 +08:00
parent 152fed3b87
commit 4390ebf5a9
283 changed files with 29231 additions and 2295 deletions

View file

@ -0,0 +1,3 @@
ALTER TABLE private_messages
DROP COLUMN IF EXISTS recipient_delivered,
DROP COLUMN IF EXISTS request_fingerprint;

View file

@ -0,0 +1,25 @@
ALTER TABLE private_messages
ADD COLUMN request_fingerprint bytea NOT NULL DEFAULT '\x',
ADD COLUMN recipient_delivered boolean NOT NULL DEFAULT false;
-- 旧行没有保存原始请求指纹,保留空 fingerprint使后续重放显式返回
-- RANDOM_ID_DUPLICATE禁止从可能已编辑的消息投影猜测/修复原请求。
-- recipient_delivered 可由同一私聊事实表精确回填,用于区分「被 block 后本就
-- 不投递」与「声明已投递但 recipient box 丢失」两种状态。
UPDATE private_messages AS p
SET recipient_delivered = true
WHERE p.sender_user_id <> p.recipient_user_id
AND EXISTS (
SELECT 1
FROM message_boxes AS b
WHERE b.private_message_id = p.id
AND b.owner_user_id = p.recipient_user_id
);
-- Keep these defaults after the expand step. A pre-0062 process does not name
-- either column in INSERT, so dropping them while that process can still serve
-- traffic turns an otherwise compatible rolling deployment into a NOT NULL
-- failure. The empty fingerprint is an explicit "unknown legacy receipt"
-- sentinel: the new replay path rejects it as RANDOM_ID_DUPLICATE before it can
-- interpret recipient_delivered. It must never be reconstructed from mutable
-- message_boxes.

View file

@ -0,0 +1,19 @@
-- Once a retained floor advances, channel_update_events below it are physically gone. Dropping
-- the checkpoint would make an old client pts look like an ordinary empty difference and silently
-- lose the required channelDifferenceTooLong recovery boundary. Refuse that irreversible down.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM public.channel_update_checkpoints
WHERE retained_through_pts > 0
) THEN
RAISE EXCEPTION
'cannot roll back channel update retention: retained floor has advanced'
USING ERRCODE = '55000';
END IF;
END
$$;
DROP INDEX IF EXISTS public.channel_update_events_retention_seek_idx;
DROP TABLE IF EXISTS public.channel_update_checkpoints;

View file

@ -0,0 +1,35 @@
-- Channel-scoped update retention checkpoint.
--
-- channel_update_events may now be pruned in bounded per-channel transactions. The checkpoint is
-- the durable protocol boundary: request pts below retained_through_pts must receive
-- updates.channelDifferenceTooLong; latest_event_date/latest_pts remain after row deletion so an
-- account-level updates.getDifference can still emit UpdateChannelTooLong for an offline member.
CREATE TABLE IF NOT EXISTS public.channel_update_checkpoints (
channel_id bigint PRIMARY KEY REFERENCES public.channels(id) ON DELETE CASCADE,
retained_through_pts integer NOT NULL DEFAULT 0 CHECK (retained_through_pts >= 0),
latest_event_date integer NOT NULL DEFAULT 0 CHECK (latest_event_date >= 0),
latest_pts integer NOT NULL DEFAULT 0 CHECK (latest_pts >= 0),
updated_at timestamp with time zone NOT NULL DEFAULT now(),
CONSTRAINT channel_update_checkpoints_floor_check CHECK (retained_through_pts <= latest_pts)
);
-- Backfill every existing channel, including channels without an event row, so read paths can use
-- the checkpoint directly without a full event-log fallback.
INSERT INTO public.channel_update_checkpoints (
channel_id, retained_through_pts, latest_event_date, latest_pts
)
SELECT c.id,
0,
COALESCE(MAX(e.date), 0)::integer,
c.pts
FROM public.channels c
LEFT JOIN public.channel_update_events e ON e.channel_id = c.id
GROUP BY c.id, c.pts
ON CONFLICT (channel_id) DO UPDATE SET
latest_event_date = GREATEST(channel_update_checkpoints.latest_event_date, EXCLUDED.latest_event_date),
latest_pts = GREATEST(channel_update_checkpoints.latest_pts, EXCLUDED.latest_pts),
updated_at = now();
-- Global retention candidate seek. Exact per-channel deletion continues to use PK(channel_id,pts).
CREATE INDEX IF NOT EXISTS channel_update_events_retention_seek_idx
ON public.channel_update_events (date, channel_id, pts);

View file

@ -0,0 +1,20 @@
-- TDesktop has no account-level differenceTooLong fallback. If any confirmed prefix has already
-- been deleted, removing this floor/observed state would turn a durable history hole into a false
-- empty difference. A rollback is safe only before retention has advanced anywhere.
DO $$
BEGIN
IF EXISTS (
SELECT 1
FROM public.user_update_retention
WHERE retained_through_pts > 0
) THEN
RAISE EXCEPTION
'cannot roll back user update retention: retained floor has advanced'
USING ERRCODE = '55000';
END IF;
END
$$;
DROP INDEX IF EXISTS public.user_update_events_retention_global_idx;
DROP TABLE IF EXISTS public.user_update_retention;
ALTER TABLE public.update_states DROP COLUMN IF EXISTS observed_pts;

View file

@ -0,0 +1,22 @@
-- 服务端已发送/构造的 update_states.pts 不是客户端确认difference 响应可能在网络中
-- 丢失。observed_pts 只由客户端后续请求实际带回的 pts或 getState 显式建立的快照
-- baseline推进retention 只能使用这个水位。
ALTER TABLE public.update_states
ADD COLUMN observed_pts integer NOT NULL DEFAULT 0 CHECK (observed_pts >= 0);
-- 账号级 durable update 安全前缀回收水位。
-- 这里只记录“所有当前授权设备都已明确确认”的连续前缀TDesktop 不支持
-- updates.differenceTooLong故该表绝不能用于任意 TTL 硬裁剪。
CREATE TABLE public.user_update_retention (
user_id bigint PRIMARY KEY REFERENCES public.users(id) ON DELETE CASCADE,
retained_through_pts integer NOT NULL DEFAULT 0 CHECK (retained_through_pts >= 0),
retained_through_date integer NOT NULL DEFAULT 0 CHECK (retained_through_date >= 0),
updated_at timestamp with time zone NOT NULL DEFAULT now()
);
CREATE INDEX user_update_retention_updated_idx
ON public.user_update_retention (updated_at, user_id);
-- retention 先按时间挑全局最老的安全候选,再按 (user_id, pts) 删除连续前缀。
CREATE INDEX user_update_events_retention_global_idx
ON public.user_update_events (date, user_id, pts);

View file

@ -0,0 +1 @@
DROP INDEX IF EXISTS public.auth_keys_orphan_retention_idx;

View file

@ -0,0 +1,4 @@
-- Bounded orphan auth-key GC seeks by creation time. Authorization and temp-key references are
-- rechecked in the DELETE statement; active raw keys are supplied by the connection registry.
CREATE INDEX IF NOT EXISTS auth_keys_orphan_retention_idx
ON public.auth_keys (created_at, auth_key_id);

View file

@ -0,0 +1,2 @@
DROP INDEX IF EXISTS dispatch_outbox_logical_shard_head_idx;
DROP INDEX IF EXISTS dispatch_outbox_user_pts_uidx;

View file

@ -0,0 +1,39 @@
-- 一条 durable user update 只能有一个在线投递任务。历史 schema 的
-- ON CONFLICT DO NOTHING 没有对应唯一键,先显式保留最早任务并删除重复项。
WITH duplicates AS (
SELECT id
FROM (
SELECT
id,
row_number() OVER (
PARTITION BY target_user_id, pts
-- 若历史重复中仍有可投递任务,优先保留它;不能让一个更早的
-- failed 副本覆盖健康 pending 副本并人为阻塞该用户 lane。
ORDER BY
CASE status
WHEN 'pending' THEN 0
WHEN 'dispatching' THEN 1
ELSE 2
END,
id ASC
) AS rn
FROM dispatch_outbox
) ranked
WHERE ranked.rn > 1
)
DELETE FROM dispatch_outbox d
USING duplicates x
WHERE d.id = x.id;
CREATE UNIQUE INDEX dispatch_outbox_user_pts_uidx
ON dispatch_outbox (target_user_id, pts);
-- ClaimDispatchOutboxShards 固定用 256 logical shards表达式必须与查询
-- 完全一致,避免每个 worker 为筛自己的 lane 全表扫描。
CREATE INDEX dispatch_outbox_logical_shard_head_idx
ON dispatch_outbox (
mod(target_user_id, 256::bigint),
target_user_id,
pts,
id
);

View file

@ -0,0 +1,7 @@
DROP INDEX IF EXISTS public.temp_auth_key_bindings_perm_idx;
DROP INDEX IF EXISTS public.auth_keys_orphan_last_used_idx;
ALTER TABLE public.auth_keys DROP COLUMN IF EXISTS last_used_at;
-- Restore the 0065 schema used by the pre-last_used orphan collector.
CREATE INDEX IF NOT EXISTS auth_keys_orphan_retention_idx
ON public.auth_keys (created_at, auth_key_id);

View file

@ -0,0 +1,17 @@
-- A physical connection touches last_used_at atomically while loading its key. Orphan GC uses
-- this watermark in addition to the in-memory active-key snapshot, closing the Get->Register
-- race without turning the per-frame encrypted fast path into a database write.
ALTER TABLE public.auth_keys
ADD COLUMN IF NOT EXISTS last_used_at timestamptz NOT NULL DEFAULT now();
CREATE INDEX IF NOT EXISTS auth_keys_orphan_last_used_idx
ON public.auth_keys (last_used_at, auth_key_id);
-- DeleteOrphaned now seeks exclusively by last_used_at. Keeping the transitional 0065
-- created_at index would duplicate auth-key insert/delete maintenance without serving a query.
DROP INDEX IF EXISTS public.auth_keys_orphan_retention_idx;
-- Delete/revoke and orphan-retention probes both resolve perm->temp bindings. The original
-- primary key only covers temp_auth_key_id, so the reverse predicate otherwise scans the table.
CREATE INDEX IF NOT EXISTS temp_auth_key_bindings_perm_idx
ON public.temp_auth_key_bindings (perm_auth_key_id);

View file

@ -0,0 +1,5 @@
ALTER TABLE public.private_messages
DROP COLUMN IF EXISTS recipient_pts,
DROP COLUMN IF EXISTS recipient_box_id,
DROP COLUMN IF EXISTS sender_pts,
DROP COLUMN IF EXISTS sender_box_id;

View file

@ -0,0 +1,15 @@
-- Exact random_id replay must not rebuild the original send result from mutable message_boxes:
-- edit advances box pts/body and delete hides the row. Keep the immutable allocation receipt on
-- the shared private message instead; 0073 adds the immutable first snapshot/delete receipt used
-- to build the client acknowledgement without allocating new pts or update facts.
ALTER TABLE public.private_messages
ADD COLUMN sender_box_id integer NOT NULL DEFAULT 0 CHECK (sender_box_id >= 0),
ADD COLUMN sender_pts integer NOT NULL DEFAULT 0 CHECK (sender_pts >= 0),
ADD COLUMN recipient_box_id integer NOT NULL DEFAULT 0 CHECK (recipient_box_id >= 0),
ADD COLUMN recipient_pts integer NOT NULL DEFAULT 0 CHECK (recipient_pts >= 0);
-- Keep zero defaults for writers released before this migration. Zero is not
-- a valid immutable receipt: when a row has a valid request fingerprint but a
-- legacy writer omitted these columns, the new replay path fails fast instead
-- of deriving the first response from mutable message_boxes. New writers
-- always persist positive sender receipt values before commit.

View file

@ -0,0 +1,12 @@
CREATE INDEX IF NOT EXISTS dispatch_outbox_logical_shard_head_idx
ON dispatch_outbox (
mod(target_user_id, 256::bigint),
target_user_id,
pts,
id
);
DROP TRIGGER IF EXISTS dispatch_outbox_delete_user_head ON dispatch_outbox;
DROP TRIGGER IF EXISTS dispatch_outbox_insert_user_head ON dispatch_outbox;
DROP FUNCTION IF EXISTS dispatch_outbox_maintain_user_head();
DROP TABLE IF EXISTS dispatch_outbox_user_heads;

View file

@ -0,0 +1,80 @@
-- Claim 只需要查看每个用户当前未完成 head。把 head 持久化后,领取复杂度由
-- “扫描全部 outbox 积压并 DISTINCT ON”降为“扫描有积压的用户 lane”。
-- 迁移期间阻止并发写,保证 backfill 与随后安装的触发器之间没有缺口。
LOCK TABLE dispatch_outbox IN SHARE ROW EXCLUSIVE MODE;
CREATE TABLE dispatch_outbox_user_heads (
target_user_id bigint PRIMARY KEY,
head_id bigint NOT NULL,
head_pts integer NOT NULL CHECK (head_pts >= 0),
logical_shard smallint GENERATED ALWAYS AS (
mod(target_user_id, 256::bigint)::smallint
) STORED,
CHECK (logical_shard >= 0 AND logical_shard < 256)
);
CREATE INDEX dispatch_outbox_user_heads_shard_idx
ON dispatch_outbox_user_heads (logical_shard, target_user_id);
INSERT INTO dispatch_outbox_user_heads (target_user_id, head_id, head_pts)
SELECT DISTINCT ON (target_user_id)
target_user_id,
id,
pts
FROM dispatch_outbox
ORDER BY target_user_id ASC, pts ASC, id ASC;
CREATE FUNCTION dispatch_outbox_maintain_user_head()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
removed_head bigint;
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO dispatch_outbox_user_heads (target_user_id, head_id, head_pts)
VALUES (NEW.target_user_id, NEW.id, NEW.pts)
ON CONFLICT (target_user_id) DO UPDATE
SET head_id = EXCLUDED.head_id,
head_pts = EXCLUDED.head_pts
WHERE (EXCLUDED.head_pts, EXCLUDED.head_id) <
(dispatch_outbox_user_heads.head_pts, dispatch_outbox_user_heads.head_id);
RETURN NULL;
END IF;
-- 删除非 head 不需要重算。删除 head 时用 (target_user_id, pts, id)
-- 索引找下一条failed head 同样会一直阻塞,直到被显式删除。
DELETE FROM dispatch_outbox_user_heads
WHERE target_user_id = OLD.target_user_id
AND head_id = OLD.id
RETURNING head_id INTO removed_head;
IF removed_head IS NOT NULL THEN
INSERT INTO dispatch_outbox_user_heads (target_user_id, head_id, head_pts)
SELECT target_user_id, id, pts
FROM dispatch_outbox
WHERE target_user_id = OLD.target_user_id
ORDER BY pts ASC, id ASC
LIMIT 1
ON CONFLICT (target_user_id) DO UPDATE
SET head_id = EXCLUDED.head_id,
head_pts = EXCLUDED.head_pts
WHERE (EXCLUDED.head_pts, EXCLUDED.head_id) <
(dispatch_outbox_user_heads.head_pts, dispatch_outbox_user_heads.head_id);
END IF;
RETURN NULL;
END;
$$;
CREATE TRIGGER dispatch_outbox_insert_user_head
AFTER INSERT ON dispatch_outbox
FOR EACH ROW
EXECUTE FUNCTION dispatch_outbox_maintain_user_head();
CREATE TRIGGER dispatch_outbox_delete_user_head
AFTER DELETE ON dispatch_outbox
FOR EACH ROW
EXECUTE FUNCTION dispatch_outbox_maintain_user_head();
-- Claim 已不再读取表达式 shard 索引;移除它避免每次 enqueue 的重复写放大。
DROP INDEX IF EXISTS dispatch_outbox_logical_shard_head_idx;

View file

@ -0,0 +1,46 @@
DROP TRIGGER IF EXISTS dispatch_outbox_update_user_head ON dispatch_outbox;
CREATE OR REPLACE FUNCTION dispatch_outbox_maintain_user_head()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
removed_head bigint;
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO dispatch_outbox_user_heads (target_user_id, head_id, head_pts)
VALUES (NEW.target_user_id, NEW.id, NEW.pts)
ON CONFLICT (target_user_id) DO UPDATE
SET head_id = EXCLUDED.head_id,
head_pts = EXCLUDED.head_pts
WHERE (EXCLUDED.head_pts, EXCLUDED.head_id) <
(dispatch_outbox_user_heads.head_pts, dispatch_outbox_user_heads.head_id);
RETURN NULL;
END IF;
DELETE FROM dispatch_outbox_user_heads
WHERE target_user_id = OLD.target_user_id
AND head_id = OLD.id
RETURNING head_id INTO removed_head;
IF removed_head IS NOT NULL THEN
INSERT INTO dispatch_outbox_user_heads (target_user_id, head_id, head_pts)
SELECT target_user_id, id, pts
FROM dispatch_outbox
WHERE target_user_id = OLD.target_user_id
ORDER BY pts ASC, id ASC
LIMIT 1
ON CONFLICT (target_user_id) DO UPDATE
SET head_id = EXCLUDED.head_id,
head_pts = EXCLUDED.head_pts
WHERE (EXCLUDED.head_pts, EXCLUDED.head_id) <
(dispatch_outbox_user_heads.head_pts, dispatch_outbox_user_heads.head_id);
END IF;
RETURN NULL;
END;
$$;
ALTER TABLE dispatch_outbox_user_heads
DROP COLUMN updated_at,
DROP COLUMN next_attempt_at,
DROP COLUMN status;

View file

@ -0,0 +1,82 @@
-- Keep claim readiness on the one-row-per-user head itself. Otherwise PostgreSQL may
-- legally reorder the head/outbox join and start from every eligible backlog row,
-- reintroducing the full-backlog scan that 0069 is meant to remove.
LOCK TABLE dispatch_outbox IN SHARE ROW EXCLUSIVE MODE;
ALTER TABLE dispatch_outbox_user_heads
ADD COLUMN status varchar(16) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'dispatching', 'failed')),
ADD COLUMN next_attempt_at timestamptz NOT NULL DEFAULT now(),
ADD COLUMN updated_at timestamptz NOT NULL DEFAULT now();
UPDATE dispatch_outbox_user_heads h
SET status = d.status,
next_attempt_at = d.next_attempt_at,
updated_at = d.updated_at
FROM dispatch_outbox d
WHERE d.target_user_id = h.target_user_id
AND d.id = h.head_id;
CREATE OR REPLACE FUNCTION dispatch_outbox_maintain_user_head()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
removed_head bigint;
BEGIN
IF TG_OP = 'INSERT' THEN
INSERT INTO dispatch_outbox_user_heads (
target_user_id, head_id, head_pts, status, next_attempt_at, updated_at
) VALUES (
NEW.target_user_id, NEW.id, NEW.pts, NEW.status, NEW.next_attempt_at, NEW.updated_at
)
ON CONFLICT (target_user_id) DO UPDATE
SET head_id = EXCLUDED.head_id,
head_pts = EXCLUDED.head_pts,
status = EXCLUDED.status,
next_attempt_at = EXCLUDED.next_attempt_at,
updated_at = EXCLUDED.updated_at
WHERE (EXCLUDED.head_pts, EXCLUDED.head_id) <
(dispatch_outbox_user_heads.head_pts, dispatch_outbox_user_heads.head_id);
RETURN NULL;
ELSIF TG_OP = 'UPDATE' THEN
UPDATE dispatch_outbox_user_heads
SET status = NEW.status,
next_attempt_at = NEW.next_attempt_at,
updated_at = NEW.updated_at
WHERE target_user_id = NEW.target_user_id
AND head_id = NEW.id;
RETURN NULL;
END IF;
DELETE FROM dispatch_outbox_user_heads
WHERE target_user_id = OLD.target_user_id
AND head_id = OLD.id
RETURNING head_id INTO removed_head;
IF removed_head IS NOT NULL THEN
INSERT INTO dispatch_outbox_user_heads (
target_user_id, head_id, head_pts, status, next_attempt_at, updated_at
)
SELECT target_user_id, id, pts, status, next_attempt_at, updated_at
FROM dispatch_outbox
WHERE target_user_id = OLD.target_user_id
ORDER BY pts ASC, id ASC
LIMIT 1
ON CONFLICT (target_user_id) DO UPDATE
SET head_id = EXCLUDED.head_id,
head_pts = EXCLUDED.head_pts,
status = EXCLUDED.status,
next_attempt_at = EXCLUDED.next_attempt_at,
updated_at = EXCLUDED.updated_at
WHERE (EXCLUDED.head_pts, EXCLUDED.head_id) <
(dispatch_outbox_user_heads.head_pts, dispatch_outbox_user_heads.head_id);
END IF;
RETURN NULL;
END;
$$;
CREATE TRIGGER dispatch_outbox_update_user_head
AFTER UPDATE OF status, next_attempt_at, updated_at ON dispatch_outbox
FOR EACH ROW
EXECUTE FUNCTION dispatch_outbox_maintain_user_head();

View file

@ -0,0 +1 @@
DROP INDEX IF EXISTS public.temp_auth_key_bindings_expiry_idx;

View file

@ -0,0 +1,5 @@
-- DeleteExpiredTempAuthKeys orders/filters the binding table by expiry and then deletes the parent
-- auth_keys rows in a bounded batch. The PK starts with temp_auth_key_id, so it cannot serve this
-- maintenance seek.
CREATE INDEX IF NOT EXISTS temp_auth_key_bindings_expiry_idx
ON public.temp_auth_key_bindings (expires_at, temp_auth_key_id);

View file

@ -0,0 +1,16 @@
CREATE INDEX IF NOT EXISTS dispatch_outbox_pending_ready_idx
ON dispatch_outbox (next_attempt_at, target_user_id, pts, id)
WHERE status = 'pending';
CREATE INDEX IF NOT EXISTS dispatch_outbox_dispatching_stale_ready_idx
ON dispatch_outbox (updated_at, target_user_id, pts, id)
WHERE status = 'dispatching';
CREATE INDEX IF NOT EXISTS dispatch_outbox_user_heads_shard_idx
ON dispatch_outbox_user_heads (logical_shard, target_user_id);
ALTER TABLE dispatch_outbox_user_heads
DROP CONSTRAINT IF EXISTS dispatch_outbox_user_heads_outbox_fkey;
DROP INDEX IF EXISTS dispatch_outbox_user_heads_dispatching_shard_idx;
DROP INDEX IF EXISTS dispatch_outbox_user_heads_pending_shard_idx;

View file

@ -0,0 +1,35 @@
-- Claim only eligible durable heads. Partial indexes keep work proportional to ready
-- user lanes instead of every account that has a deferred/failed backlog.
CREATE INDEX dispatch_outbox_user_heads_pending_shard_idx
ON dispatch_outbox_user_heads (
logical_shard,
next_attempt_at,
target_user_id,
head_pts,
head_id
)
WHERE status = 'pending';
CREATE INDEX dispatch_outbox_user_heads_dispatching_shard_idx
ON dispatch_outbox_user_heads (
logical_shard,
updated_at,
target_user_id,
head_pts,
head_id
)
WHERE status = 'dispatching';
-- A head must always reference the exact outbox row it represents. The base schema already has
-- dispatch_outbox_target_user_id_id_key on these columns; reuse it instead of maintaining a
-- duplicate unique index on every enqueue/delete. Deferred validation lets the AFTER DELETE
-- trigger promote/delete the head in the same tx.
ALTER TABLE dispatch_outbox_user_heads
ADD CONSTRAINT dispatch_outbox_user_heads_outbox_fkey
FOREIGN KEY (target_user_id, head_id)
REFERENCES dispatch_outbox (target_user_id, id)
DEFERRABLE INITIALLY DEFERRED;
DROP INDEX IF EXISTS dispatch_outbox_user_heads_shard_idx;
DROP INDEX IF EXISTS dispatch_outbox_pending_ready_idx;
DROP INDEX IF EXISTS dispatch_outbox_dispatching_stale_ready_idx;

View file

@ -0,0 +1,17 @@
ALTER TABLE public.channel_messages
DROP CONSTRAINT IF EXISTS channel_messages_delete_ids_array,
DROP CONSTRAINT IF EXISTS channel_messages_send_snapshot_object,
DROP COLUMN IF EXISTS delete_message_ids,
DROP COLUMN IF EXISTS delete_date,
DROP COLUMN IF EXISTS delete_pts_count,
DROP COLUMN IF EXISTS delete_pts,
DROP COLUMN IF EXISTS send_snapshot;
ALTER TABLE public.private_messages
DROP CONSTRAINT IF EXISTS private_messages_sender_delete_ids_array,
DROP CONSTRAINT IF EXISTS private_messages_sender_snapshot_object,
DROP COLUMN IF EXISTS sender_delete_message_ids,
DROP COLUMN IF EXISTS sender_delete_date,
DROP COLUMN IF EXISTS sender_delete_pts_count,
DROP COLUMN IF EXISTS sender_delete_pts,
DROP COLUMN IF EXISTS sender_snapshot;

View file

@ -0,0 +1,23 @@
-- Lost-response random_id replay must be able to acknowledge the original send
-- after mutable message rows were edited or deleted. Keep the first sender echo
-- and the exact durable delete event alongside the idempotency key.
ALTER TABLE public.private_messages
ADD COLUMN sender_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN sender_delete_pts integer NOT NULL DEFAULT 0 CHECK (sender_delete_pts >= 0),
ADD COLUMN sender_delete_pts_count integer NOT NULL DEFAULT 0 CHECK (sender_delete_pts_count >= 0),
ADD COLUMN sender_delete_date integer NOT NULL DEFAULT 0 CHECK (sender_delete_date >= 0),
ADD COLUMN sender_delete_message_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
ADD CONSTRAINT private_messages_sender_snapshot_object CHECK (jsonb_typeof(sender_snapshot) IS NOT DISTINCT FROM 'object'),
ADD CONSTRAINT private_messages_sender_delete_ids_array CHECK (jsonb_typeof(sender_delete_message_ids) IS NOT DISTINCT FROM 'array');
ALTER TABLE public.channel_messages
ADD COLUMN send_snapshot jsonb NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN delete_pts integer NOT NULL DEFAULT 0 CHECK (delete_pts >= 0),
ADD COLUMN delete_pts_count integer NOT NULL DEFAULT 0 CHECK (delete_pts_count >= 0),
ADD COLUMN delete_date integer NOT NULL DEFAULT 0 CHECK (delete_date >= 0),
ADD COLUMN delete_message_ids jsonb NOT NULL DEFAULT '[]'::jsonb,
ADD CONSTRAINT channel_messages_send_snapshot_object CHECK (jsonb_typeof(send_snapshot) IS NOT DISTINCT FROM 'object'),
ADD CONSTRAINT channel_messages_delete_ids_array CHECK (jsonb_typeof(delete_message_ids) IS NOT DISTINCT FROM 'array');
-- Existing rows predate immutable replay snapshots. Leaving them as {} makes a
-- replay fail fast instead of guessing the first response from mutable state.

View file

@ -0,0 +1 @@
DROP INDEX IF EXISTS public.dispatch_outbox_user_heads_failed_cleanup_idx;

View file

@ -0,0 +1,10 @@
-- Terminal failed rows are short-lived diagnostic quarantine entries. Cleanup starts from the
-- one-row-per-user durable head so it can lock in the same head→outbox order as claim/completion
-- without scanning healthy lanes.
CREATE INDEX dispatch_outbox_user_heads_failed_cleanup_idx
ON public.dispatch_outbox_user_heads (
updated_at,
target_user_id,
head_id
)
WHERE status = 'failed';

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS public.uploaded_media_receipts;

View file

@ -0,0 +1,12 @@
-- upload.saveFilePart data is transient, while messages.sendMedia may be replayed after its first
-- response is lost. Preserve the immutable Photo/Document materialization selected for each
-- (owner,file_id) so InputMediaUploaded* remains idempotent after part cleanup.
CREATE TABLE public.uploaded_media_receipts (
owner_user_id bigint NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
file_id bigint NOT NULL CHECK (file_id <> 0),
intent_hash bytea NOT NULL CHECK (octet_length(intent_hash) = 32),
media_kind text NOT NULL CHECK (media_kind IN ('photo', 'document')),
media_id bigint NOT NULL CHECK (media_id <> 0),
created_at timestamptz NOT NULL DEFAULT now(),
PRIMARY KEY (owner_user_id, file_id)
);

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS album_group_reservations;

View file

@ -0,0 +1,17 @@
-- sendMultiMedia 必须在解析上传媒体、逐条发送之前持久预留 grouped_id。
-- 这张表把每个发送 random_id 固定到会话作用域内的相册组,使中途失败后
-- 客户端只重试失败子集时仍能恢复首次整包使用的 grouped_idintent_hash
-- 同时阻止内容已改变的相同 random_id 借旧预留错误合并。
CREATE TABLE album_group_reservations (
sender_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
peer_type TEXT NOT NULL CHECK (peer_type IN ('user', 'channel')),
peer_id BIGINT NOT NULL CHECK (peer_id > 0),
random_id BIGINT NOT NULL CHECK (random_id <> 0),
intent_hash BYTEA NOT NULL CHECK (octet_length(intent_hash) = 32),
grouped_id BIGINT NOT NULL CHECK (grouped_id <> 0),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
PRIMARY KEY (sender_user_id, peer_type, peer_id, random_id)
);
COMMENT ON TABLE album_group_reservations IS
'Durable pre-send binding from album item random_id to grouped_id; never reconstructed from a retry subset.';

View file

@ -0,0 +1,8 @@
-- Contract rollback: only safe after every pre-0062/0068 writer has drained.
ALTER TABLE public.private_messages
ALTER COLUMN request_fingerprint DROP DEFAULT,
ALTER COLUMN recipient_delivered DROP DEFAULT,
ALTER COLUMN sender_box_id DROP DEFAULT,
ALTER COLUMN sender_pts DROP DEFAULT,
ALTER COLUMN recipient_box_id DROP DEFAULT,
ALTER COLUMN recipient_pts DROP DEFAULT;

View file

@ -0,0 +1,17 @@
-- 0062 and 0068 originally removed their expand-phase defaults immediately.
-- Databases that already applied those revisions therefore reject INSERTs from
-- an older telesrv process during a rolling deployment. Restore the permanent
-- legacy-writer sentinels; fresh databases receive the same defaults directly
-- from the corrected original migrations.
ALTER TABLE public.private_messages
ALTER COLUMN request_fingerprint SET DEFAULT '\x'::bytea,
ALTER COLUMN recipient_delivered SET DEFAULT false,
ALTER COLUMN sender_box_id SET DEFAULT 0,
ALTER COLUMN sender_pts SET DEFAULT 0,
ALTER COLUMN recipient_box_id SET DEFAULT 0,
ALTER COLUMN recipient_pts SET DEFAULT 0;
-- Empty fingerprint and zero receipt fields mean "unknown legacy send". They
-- are deliberately not backfilled: the immutable first response cannot be
-- reconstructed from message_boxes after edit/delete. Replay must reject or
-- fail fast through the store invariants instead of normalizing these values.

View file

@ -0,0 +1,3 @@
ALTER TABLE public.channel_messages
DROP CONSTRAINT IF EXISTS channel_messages_request_fingerprint_size,
DROP COLUMN IF EXISTS request_fingerprint;

View file

@ -0,0 +1,16 @@
-- Persist the immutable client intent beside a channel random_id receipt.
--
-- The empty default is intentional for rolling deploys: binaries that predate
-- this migration can continue to insert channel/service messages. Empty
-- fingerprints are legacy/unknown receipts and the replay path rejects them;
-- it never guesses intent from an editable message projection.
ALTER TABLE public.channel_messages
ADD COLUMN request_fingerprint bytea NOT NULL DEFAULT '\x';
ALTER TABLE public.channel_messages
ADD CONSTRAINT channel_messages_request_fingerprint_size
CHECK (octet_length(request_fingerprint) IN (0, 32)) NOT VALID;
-- NOT VALID avoids a blocking historical-table validation scan during the
-- rolling migration while PostgreSQL still enforces the check for every new
-- or updated row. A later maintenance window may validate it online.

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS login_code_message_deliveries;

View file

@ -0,0 +1,22 @@
-- A phone_code_hash identifies exactly one account-visible 777000 login-code
-- notification. Store only its SHA-256 digest plus compact immutable allocation
-- facts; the secret code body remains solely in private_messages/message_boxes.
CREATE TABLE login_code_message_deliveries (
delivery_key bytea PRIMARY KEY,
code_fingerprint bytea NOT NULL,
user_id bigint NOT NULL REFERENCES users(id) ON DELETE CASCADE,
private_message_id bigint NOT NULL CHECK (private_message_id > 0),
message_box_id integer NOT NULL CHECK (message_box_id > 0),
pts integer NOT NULL CHECK (pts > 0),
message_date integer NOT NULL CHECK (message_date > 0),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT login_code_message_deliveries_key_size CHECK (octet_length(delivery_key) = 32),
CONSTRAINT login_code_message_deliveries_fingerprint_size CHECK (octet_length(code_fingerprint) = 32),
CONSTRAINT login_code_message_deliveries_user_box_unique UNIQUE (user_id, message_box_id),
CONSTRAINT login_code_message_deliveries_user_pts_unique UNIQUE (user_id, pts)
);
COMMENT ON COLUMN login_code_message_deliveries.delivery_key IS
'SHA-256(phone_code_hash); raw phone_code_hash is never persisted';
COMMENT ON COLUMN login_code_message_deliveries.code_fingerprint IS
'HMAC-SHA-256(code), keyed by the non-persisted raw phone_code_hash';

View file

@ -0,0 +1,4 @@
DROP INDEX IF EXISTS login_code_message_deliveries_expiry_idx;
ALTER TABLE login_code_message_deliveries
DROP COLUMN IF EXISTS expires_at;

View file

@ -0,0 +1,15 @@
-- Compact login-code idempotency receipts are only needed while the opaque
-- code can still be used/replayed. Keep them seek-prunable instead of growing
-- one row per login attempt forever.
ALTER TABLE login_code_message_deliveries
ADD COLUMN expires_at timestamptz;
UPDATE login_code_message_deliveries
SET expires_at = created_at + interval '24 hours'
WHERE expires_at IS NULL;
ALTER TABLE login_code_message_deliveries
ALTER COLUMN expires_at SET NOT NULL;
CREATE INDEX login_code_message_deliveries_expiry_idx
ON login_code_message_deliveries (expires_at, delivery_key);