merged with fixes
This commit is contained in:
parent
a9e758b712
commit
2f1818d656
176 changed files with 9000 additions and 907 deletions
|
|
@ -95,6 +95,9 @@ func newActiveChannelIDsPageBatcher(
|
|||
queue: make(chan activeChannelIDsBatchRequest, cfg.QueueSize),
|
||||
stop: make(chan struct{}), done: make(chan struct{}), cancel: cancel,
|
||||
}
|
||||
if cfg.Metrics != nil {
|
||||
cfg.Metrics.ActiveChannelIDsPending(0)
|
||||
}
|
||||
go b.run(workerCtx)
|
||||
return b, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,13 +3,34 @@ package postgres
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
obsmetrics "telesrv/internal/observability/metrics"
|
||||
)
|
||||
|
||||
func TestActiveChannelIDsPageBatcherExportsIdlePending(t *testing.T) {
|
||||
registry := obsmetrics.New()
|
||||
backend := &fakeActiveChannelIDsBatchBackend{}
|
||||
batcher, err := newActiveChannelIDsPageBatcher(backend, ActiveChannelIDsBatchConfig{
|
||||
MaxSize: 1, MaxWait: time.Millisecond, QueueSize: 1, QueryTimeout: time.Second, Metrics: registry,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(batcher.Close)
|
||||
recorder := httptest.NewRecorder()
|
||||
registry.ServeHTTP(recorder, httptest.NewRequest("GET", "/metrics", nil))
|
||||
if !strings.Contains(recorder.Body.String(), "telesrv_active_channel_ids_pending 0\n") || backend.calls.Load() != 0 {
|
||||
t.Fatalf("idle owner must expose zero pending without loading a page: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDistinctActiveChannelIDsBatchDefersDuplicate(t *testing.T) {
|
||||
selector := activeChannelIDsSelector{userID: 1, limit: 1000}
|
||||
first := activeChannelIDsBatchRequest{selector: selector}
|
||||
|
|
|
|||
|
|
@ -215,10 +215,11 @@ UPDATE authorizations SET
|
|||
system_version = CASE WHEN $5 <> '' THEN $5 ELSE system_version END,
|
||||
api_id = CASE WHEN $6 <> 0 THEN $6 ELSE api_id END,
|
||||
app_version = CASE WHEN $7 <> '' THEN $7 ELSE app_version END,
|
||||
ip = CASE WHEN $8 <> '' THEN $8 ELSE ip END,
|
||||
active_at = now()
|
||||
WHERE auth_key_id = $1`,
|
||||
authKeyIDToInt64(id), int32(info.Layer), info.DeviceModel, info.Platform,
|
||||
info.SystemVersion, int32(info.APIID), info.AppVersion,
|
||||
info.SystemVersion, int32(info.APIID), info.AppVersion, info.IP,
|
||||
); err != nil {
|
||||
return fmt.Errorf("update authorization client info: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,7 @@ func AcquireBlobMigrationLock(ctx context.Context, dsn string) (*BlobStorageLock
|
|||
}
|
||||
|
||||
func acquireBlobStorageLock(ctx context.Context, dsn string, shared bool) (*BlobStorageLock, error) {
|
||||
conn, err := pgx.Connect(ctx, dsn)
|
||||
conn, err := connectPostgresAdmitted(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("connect for blob storage lock: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -99,6 +99,9 @@ func newBatchedBootstrapUpdateJobStore(
|
|||
queue: make(chan bootstrapReadyBatchRequest, cfg.QueueSize),
|
||||
stop: make(chan struct{}), done: make(chan struct{}), cancel: cancel,
|
||||
}
|
||||
if cfg.Metrics != nil {
|
||||
cfg.Metrics.BootstrapReadyPending(0)
|
||||
}
|
||||
go s.run(workerCtx)
|
||||
return s, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,14 +3,34 @@ package postgres
|
|||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
obsmetrics "telesrv/internal/observability/metrics"
|
||||
)
|
||||
|
||||
func TestBatchedBootstrapUpdateJobStoreExportsIdlePending(t *testing.T) {
|
||||
registry := obsmetrics.New()
|
||||
backend := &fakeBootstrapReadyBackend{}
|
||||
batcher, err := newBatchedBootstrapUpdateJobStore(backend, BootstrapReadyBatchConfig{
|
||||
MaxSize: 1, MaxWait: time.Millisecond, QueueSize: 1, QueryTimeout: time.Second, Metrics: registry,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(batcher.Close)
|
||||
recorder := httptest.NewRecorder()
|
||||
registry.ServeHTTP(recorder, httptest.NewRequest("GET", "/metrics", nil))
|
||||
if !strings.Contains(recorder.Body.String(), "telesrv_bootstrap_ready_pending 0\n") || backend.calls.Load() != 0 {
|
||||
t.Fatalf("idle owner must expose zero pending without a readiness query: %s", recorder.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectDistinctBootstrapReadyBatchDefersSameFence(t *testing.T) {
|
||||
first := bootstrapReadyBatchRequest{userID: 1, authKeyID: [8]byte{1}, sessionID: 10}
|
||||
duplicate := bootstrapReadyBatchRequest{userID: 1, authKeyID: [8]byte{1}, sessionID: 11}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,90 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestChannelDifferenceValidCursorBeforeRowCacheInvalidationPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
owner, err := NewUserStore(pool).Create(ctx, domain.User{
|
||||
AccessHash: 731, Phone: "+1994" + randomSuffix(t) + "01", FirstName: "CursorOwner",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var channelID int64
|
||||
t.Cleanup(func() {
|
||||
if channelID != 0 {
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id = $1", channelID)
|
||||
}
|
||||
_, _ = pool.Exec(ctx, "DELETE FROM users WHERE id = $1", owner.ID)
|
||||
})
|
||||
cache := NewChannelRowCache(16)
|
||||
channels := NewChannelStore(pool, WithChannelRowCache(cache))
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{
|
||||
CreatorUserID: owner.ID, Title: "Cursor notification window", Megagroup: true, Date: 1701000300,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channelID = created.Channel.ID
|
||||
// Populate the real read-through cache, then commit a real send. Keeping
|
||||
// the listener paused models the interval between commit/push and NOTIFY
|
||||
// consumption; no persisted cursor or event is fabricated.
|
||||
if _, _, _, err := channels.getChannelForViewer(ctx, pool, owner.ID, channelID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sent, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, RandomID: 1701000301, Message: "committed cursor", Date: 1701000301,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if cached, ok := cache.get(channelID); !ok || cached.Pts != created.Channel.Pts {
|
||||
t.Fatalf("expected pre-notification cached pts %d, got %d (cached=%v)", created.Channel.Pts, cached.Pts, ok)
|
||||
}
|
||||
for _, pts := range []int{-1, sent.Event.Pts + 1} {
|
||||
if _, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, Pts: pts, Limit: 100,
|
||||
}); !errors.Is(err, domain.ErrPersistentTimestamp) {
|
||||
t.Fatalf("invalid cursor %d error = %v, want ErrPersistentTimestamp", pts, err)
|
||||
}
|
||||
}
|
||||
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, Pts: sent.Event.Pts, Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("valid committed cursor rejected before cache invalidation: %v", err)
|
||||
}
|
||||
if !diff.Final || diff.Pts != sent.Event.Pts || len(diff.Events) != 0 || diff.TooLong {
|
||||
t.Fatalf("current committed cursor difference = %+v", diff)
|
||||
}
|
||||
// A retry must still load the durable tail, not turn every now-valid
|
||||
// cursor into an empty response at the latest PTS.
|
||||
second, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, RandomID: 1701000302, Message: "second cursor", Date: 1701000302,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
third, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, RandomID: 1701000303, Message: "durable tail", Date: 1701000303,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
diff, err = channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: owner.ID, ChannelID: channelID, Pts: second.Event.Pts, Limit: 100,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !diff.Final || diff.Pts != third.Event.Pts || len(diff.NewMessages) != 1 || diff.NewMessages[0].ID != third.Message.ID {
|
||||
t.Fatalf("committed cursor retry lost durable tail: %+v", diff)
|
||||
}
|
||||
}
|
||||
|
|
@ -436,7 +436,7 @@ func TestChannelStoreSendFailureBeforePtsAllocationDoesNotRecordNoopGap(t *testi
|
|||
if err := pool.QueryRow(ctx, `
|
||||
SELECT count(*)::int
|
||||
FROM channel_update_events
|
||||
WHERE channel_id = $1 AND pts = 2`, channelID).Scan(&gapRows); err != nil {
|
||||
WHERE channel_id = $1 AND pts > $2`, channelID, created.Channel.Pts).Scan(&gapRows); err != nil {
|
||||
t.Fatalf("count events after failed send: %v", err)
|
||||
}
|
||||
if gapRows != 0 {
|
||||
|
|
@ -453,20 +453,20 @@ WHERE channel_id = $1 AND pts = 2`, channelID).Scan(&gapRows); err != nil {
|
|||
if err != nil {
|
||||
t.Fatalf("send owner after gap: %v", err)
|
||||
}
|
||||
if sent.Event.Pts != 2 {
|
||||
t.Fatalf("next channel pts = %d, want 2 after failed send before pts allocation", sent.Event.Pts)
|
||||
if sent.Event.Pts != created.Channel.Pts+1 {
|
||||
t.Fatalf("next channel pts = %d, want %d after failed send before pts allocation", sent.Event.Pts, created.Channel.Pts+1)
|
||||
}
|
||||
diff, err := channels.ListChannelDifference(ctx, domain.ChannelDifferenceRequest{
|
||||
UserID: owner.ID,
|
||||
ChannelID: channelID,
|
||||
Pts: 1,
|
||||
Pts: created.Channel.Pts,
|
||||
Limit: 10,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("list channel difference: %v", err)
|
||||
}
|
||||
if diff.Pts != 2 || len(diff.Events) != 1 || diff.Events[0].Type != domain.ChannelUpdateNewMessage || diff.Events[0].Pts != 2 {
|
||||
t.Fatalf("diff after failed send = %+v, want only message pts=2", diff)
|
||||
if diff.Pts != sent.Event.Pts || len(diff.Events) != 1 || diff.Events[0].Type != domain.ChannelUpdateNewMessage || diff.Events[0].Pts != sent.Event.Pts {
|
||||
t.Fatalf("diff after failed send = %+v, want only message pts=%d", diff, sent.Event.Pts)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
|
|
@ -66,7 +65,7 @@ func (l *ChannelChangeListener) Run(ctx context.Context) {
|
|||
// listenAndConsume 建立一条连接、LISTEN、flush,然后消费通知直到出错或 ctx 取消。
|
||||
// 成功消费到通知前不重置退避,由 Run 控制重连节奏。
|
||||
func (l *ChannelChangeListener) listenAndConsume(ctx context.Context) error {
|
||||
conn, err := pgx.Connect(ctx, l.dsn)
|
||||
conn, err := connectPostgresAdmitted(ctx, l.dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
28
internal/store/postgres/delivery_effect.go
Normal file
28
internal/store/postgres/delivery_effect.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/store"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
func applyDeliveryEffectsTx(ctx context.Context, tx pgx.Tx, effects []store.DeliveryEffect) ([]store.DeliveryEffect, error) {
|
||||
qtx := sqlcgen.New(tx)
|
||||
events := NewUpdateEventStore(tx)
|
||||
for i := range effects {
|
||||
effect := &effects[i]
|
||||
if err := effect.Validate(); err != nil {
|
||||
return nil, fmt.Errorf("delivery effect %d: %w", i, err)
|
||||
}
|
||||
event, err := events.appendInTx(ctx, tx, qtx, effect.TargetUserID, effect.Event, true, effect.ExcludeAuthKeyID, effect.ExcludeSessionID, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("apply account PTS delivery effect %d: %w", i, err)
|
||||
}
|
||||
effect.Event = event
|
||||
}
|
||||
return effects, nil
|
||||
}
|
||||
|
|
@ -209,6 +209,7 @@ func (s *DialogStore) listByUser(ctx context.Context, userID int64, filter domai
|
|||
row.MessageQuoteText,
|
||||
row.MessageQuoteEntitiesJson,
|
||||
row.MessageQuoteOffset,
|
||||
row.MessageReplyExternalJson,
|
||||
row.MessageFwdFromPeerType,
|
||||
row.MessageFwdFromPeerID,
|
||||
row.MessageFwdFromName,
|
||||
|
|
@ -551,6 +552,7 @@ func (s *DialogStore) ListByPeers(ctx context.Context, userID int64, peers []dom
|
|||
row.MessageQuoteText,
|
||||
row.MessageQuoteEntitiesJson,
|
||||
row.MessageQuoteOffset,
|
||||
row.MessageReplyExternalJson,
|
||||
row.MessageFwdFromPeerType,
|
||||
row.MessageFwdFromPeerID,
|
||||
row.MessageFwdFromName,
|
||||
|
|
|
|||
|
|
@ -173,7 +173,10 @@ func (s *MessageStore) DeliverLoginCodeMessage(ctx context.Context, req domain.L
|
|||
if err != nil {
|
||||
return domain.LoginCodeDeliveryResult{}, fmt.Errorf("create login code recipient box: %w", err)
|
||||
}
|
||||
msg := messageFromBoxRow(boxRow)
|
||||
msg, err := messageFromBoxRow(boxRow)
|
||||
if err != nil {
|
||||
return domain.LoginCodeDeliveryResult{}, err
|
||||
}
|
||||
|
||||
if err := qtx.UpsertInboxDialog(ctx, sqlcgen.UpsertInboxDialogParams{
|
||||
UserID: req.UserID,
|
||||
|
|
|
|||
106
internal/store/postgres/media_pagination_test.go
Normal file
106
internal/store/postgres/media_pagination_test.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"telesrv/internal/domain"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMediaPaginationBoundaries(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
pool := testPool(t)
|
||||
users := NewUserStore(pool)
|
||||
suffix := randomSuffix(t)
|
||||
a, err := users.Create(ctx, domain.User{AccessHash: 61, Phone: "+1677" + suffix + "01", FirstName: "Media A"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := users.Create(ctx, domain.User{AccessHash: 62, Phone: "+1677" + suffix + "02", FirstName: "Media B"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, other := a.ID, b.ID
|
||||
messages := newTestMessageStore(pool)
|
||||
channels := newTestChannelStore(pool)
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=ANY($1::bigint[])", []int64{owner, other}) })
|
||||
created, err := channels.CreateChannel(ctx, domain.CreateChannelRequest{CreatorUserID: owner, Title: "media boundaries", Megagroup: true, MemberUserIDs: []int64{other}, Date: 1700000000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
channel := created.Channel.ID
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM channels WHERE id=$1", channel) })
|
||||
|
||||
var privateIDs, channelIDs []int
|
||||
for i := 1; i <= 12; i++ {
|
||||
var media *domain.MessageMedia
|
||||
if i%2 == 1 {
|
||||
media = &domain.MessageMedia{Kind: domain.MessageMediaKindPhoto, Photo: &domain.Photo{ID: int64(i), AccessHash: 99}}
|
||||
}
|
||||
a, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: owner, RecipientUserID: other, RandomID: int64(i), Message: "media boundary", Media: media, Date: 1700000000 + i})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := channels.SendChannelMessage(ctx, domain.SendChannelMessageRequest{UserID: owner, ChannelID: channel, RandomID: int64(i), Message: "media boundary", Media: media, Date: 1700000000 + i})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if media != nil {
|
||||
privateIDs = append([]int{a.SenderMessage.ID}, privateIDs...)
|
||||
channelIDs = append([]int{c.Message.ID}, channelIDs...)
|
||||
}
|
||||
}
|
||||
for _, side := range []string{"private", "channel"} {
|
||||
ids := privateIDs
|
||||
if side == "channel" {
|
||||
ids = channelIDs
|
||||
}
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
f domain.MediaSearchRequest
|
||||
want []int
|
||||
count int
|
||||
}{
|
||||
{"all", domain.MediaSearchRequest{Limit: 100}, ids, 6},
|
||||
{"strict-range", domain.MediaSearchRequest{Limit: 100, MinID: ids[4], MaxID: ids[1]}, ids[2:4], 2},
|
||||
{"strict-count", domain.MediaSearchRequest{Limit: 0, MinID: ids[4], MaxID: ids[1], OffsetID: ids[2], AddOffset: -2}, nil, 2},
|
||||
{"around-existing", domain.MediaSearchRequest{Limit: 4, OffsetID: ids[2], AddOffset: -2}, ids[1:5], 6},
|
||||
{"after-missing", domain.MediaSearchRequest{Limit: 2, OffsetID: ids[4] + 1, AddOffset: -2}, ids[2:4], 6},
|
||||
{"forward-gap", domain.MediaSearchRequest{Limit: 2, OffsetID: ids[4], AddOffset: -4}, ids[1:3], 6},
|
||||
{"empty-forward", domain.MediaSearchRequest{Limit: 2, OffsetID: ids[0] + 1, AddOffset: -2}, nil, 6},
|
||||
{"around-top", domain.MediaSearchRequest{Limit: 4, OffsetID: ids[0] + 1, AddOffset: -2}, ids[:2], 6},
|
||||
{"around-zero", domain.MediaSearchRequest{Limit: 4, AddOffset: -2}, ids[:2], 6},
|
||||
{"empty-backward", domain.MediaSearchRequest{Limit: 2, AddOffset: 100}, nil, 6},
|
||||
} {
|
||||
t.Run(side+"/"+tc.name, func(t *testing.T) {
|
||||
f := tc.f
|
||||
f.Categories = []domain.MediaCategory{domain.MediaCategoryPhoto, domain.MediaCategoryPhoto}
|
||||
f.Query = "boundary"
|
||||
var got []int
|
||||
var count int
|
||||
if side == "private" {
|
||||
r, err := messages.SearchPrivateMedia(ctx, owner, other, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count = r.Count
|
||||
for _, m := range r.Messages {
|
||||
got = append(got, m.ID)
|
||||
}
|
||||
} else {
|
||||
r, err := channels.SearchChannelMedia(ctx, owner, channel, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
count = r.Count
|
||||
for _, m := range r.Messages {
|
||||
got = append(got, m.ID)
|
||||
}
|
||||
}
|
||||
if count != tc.count || !reflect.DeepEqual(append([]int{}, got...), append([]int{}, tc.want...)) {
|
||||
t.Fatalf("ids=%v count=%d want %v/%d", got, count, tc.want, tc.count)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -29,13 +29,57 @@ func mediaSearchPaging(req domain.MediaSearchRequest) (limit, offset int) {
|
|||
if limit < 0 || limit > mediaSearchPageLimit {
|
||||
limit = mediaSearchPageLimit
|
||||
}
|
||||
offset = req.AddOffset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
offset = domain.ClampMessageHistoryAddOffset(req.AddOffset)
|
||||
return limit, offset
|
||||
}
|
||||
|
||||
// mediaSearchIDs keeps both sides of the anchor bounded before hydration. The
|
||||
// forward side includes offset_id; the backward side is strictly older. An
|
||||
// absent side stays empty rather than moving the requested window. This
|
||||
// covers add_offset's negative range (Telegram lets a caller ask for items
|
||||
// straddling offset_id, e.g. "some before and some after") which the older
|
||||
// offset>=0-only OFFSET/LIMIT query could not express -- a negative
|
||||
// add_offset used to just get clamped to 0.
|
||||
func mediaSearchIDs(ctx context.Context, db interface {
|
||||
Query(context.Context, string, ...any) (pgx.Rows, error)
|
||||
}, column, base string, baseArgs []any, req domain.MediaSearchRequest) ([]int, error) {
|
||||
limit, add := mediaSearchPaging(req)
|
||||
args := append([]any(nil), baseArgs...)
|
||||
older, newer := "", " AND FALSE"
|
||||
if req.OffsetID > 0 {
|
||||
args = append(args, pgInt32NonNegative(req.OffsetID))
|
||||
older = fmt.Sprintf(" AND %s < $%d", column, len(args))
|
||||
newer = fmt.Sprintf(" AND %s >= $%d", column, len(args))
|
||||
}
|
||||
part := func(anchor, direction string, offset, n int) string {
|
||||
args = append(args, offset, n)
|
||||
return fmt.Sprintf("(SELECT DISTINCT %s AS id%s%s ORDER BY id %s OFFSET $%d LIMIT $%d)", column, base, anchor, direction, len(args)-1, len(args))
|
||||
}
|
||||
var query string
|
||||
switch {
|
||||
case add >= 0:
|
||||
query = part(older, "DESC", add, limit)
|
||||
case add+limit <= 0:
|
||||
query = part(newer, "ASC", -add-limit, limit)
|
||||
default:
|
||||
query = part(newer, "ASC", 0, -add) + " UNION ALL " + part(older, "DESC", 0, limit+add)
|
||||
}
|
||||
rows, err := db.Query(ctx, "SELECT id FROM ("+query+") page ORDER BY id DESC", args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := make([]int, 0, limit)
|
||||
for rows.Next() {
|
||||
var id int32
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ids = append(ids, int(id))
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
type mediaSearchQueryer interface {
|
||||
QueryRow(ctx context.Context, sql string, args ...any) pgx.Row
|
||||
}
|
||||
|
|
@ -88,10 +132,10 @@ WHERE mi.owner_user_id = $1 AND mi.peer_id = $2 AND mi.category = ANY($3::smalli
|
|||
where += fmt.Sprintf(clause, len(args))
|
||||
}
|
||||
if req.MaxID > 0 {
|
||||
add(" AND mi.box_id <= $%d", pgInt32NonNegative(req.MaxID))
|
||||
add(" AND mi.box_id < $%d", pgInt32NonNegative(req.MaxID))
|
||||
}
|
||||
if req.MinID > 0 {
|
||||
add(" AND mi.box_id >= $%d", pgInt32NonNegative(req.MinID))
|
||||
add(" AND mi.box_id > $%d", pgInt32NonNegative(req.MinID))
|
||||
}
|
||||
if req.Query != "" {
|
||||
add(" AND mb.body ILIKE '%%' || $%d || '%%'", req.Query)
|
||||
|
|
@ -152,10 +196,10 @@ WHERE mi.channel_id = $1 AND mi.category = ANY($2::smallint[])
|
|||
}
|
||||
}
|
||||
if req.MaxID > 0 {
|
||||
add(" AND mi.id <= $%d", pgInt32NonNegative(req.MaxID))
|
||||
add(" AND mi.id < $%d", pgInt32NonNegative(req.MaxID))
|
||||
}
|
||||
if req.MinID > 0 {
|
||||
add(" AND mi.id >= $%d", pgInt32NonNegative(req.MinID))
|
||||
add(" AND mi.id > $%d", pgInt32NonNegative(req.MinID))
|
||||
}
|
||||
if req.Query != "" {
|
||||
add(" AND m.body ILIKE '%%' || $%d || '%%'", req.Query)
|
||||
|
|
@ -182,7 +226,7 @@ func (s *MessageStore) SearchPrivateMedia(ctx context.Context, ownerUserID, peer
|
|||
if ownerUserID == 0 || peerID == 0 || len(cats) == 0 {
|
||||
return domain.MessageList{}, nil
|
||||
}
|
||||
limit, offset := mediaSearchPaging(req)
|
||||
limit, _ := mediaSearchPaging(req)
|
||||
base, baseArgs := privateMediaSearchBase(ownerUserID, peerID, cats, req)
|
||||
|
||||
count := req.KnownCount
|
||||
|
|
@ -196,28 +240,9 @@ func (s *MessageStore) SearchPrivateMedia(ctx context.Context, ownerUserID, peer
|
|||
if limit == 0 {
|
||||
return domain.MessageList{Count: count}, nil
|
||||
}
|
||||
args := append([]any(nil), baseArgs...)
|
||||
if req.OffsetID > 0 {
|
||||
args = append(args, pgInt32NonNegative(req.OffsetID))
|
||||
base += fmt.Sprintf(" AND mi.box_id < $%d", len(args))
|
||||
}
|
||||
args = append(args, offset, limit)
|
||||
rows, err := s.db.Query(ctx, "SELECT DISTINCT mi.box_id"+base+
|
||||
fmt.Sprintf(" ORDER BY mi.box_id DESC OFFSET $%d LIMIT $%d", len(args)-1, len(args)), args...)
|
||||
ids, err := mediaSearchIDs(ctx, s.db, "mi.box_id", base, baseArgs, req)
|
||||
if err != nil {
|
||||
return domain.MessageList{}, fmt.Errorf("list private media ids: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := make([]int, 0, limit)
|
||||
for rows.Next() {
|
||||
var id int32
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return domain.MessageList{}, fmt.Errorf("scan private media id: %w", err)
|
||||
}
|
||||
ids = append(ids, int(id))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.MessageList{}, fmt.Errorf("iterate private media ids: %w", err)
|
||||
return domain.MessageList{}, fmt.Errorf("list media ids: %w", err)
|
||||
}
|
||||
|
||||
list, err := s.GetByIDs(ctx, ownerUserID, ids)
|
||||
|
|
@ -263,7 +288,7 @@ func (s *ChannelStore) SearchChannelMedia(ctx context.Context, viewerUserID, cha
|
|||
if viewerUserID == 0 || channelID == 0 || len(cats) == 0 {
|
||||
return domain.ChannelHistory{}, nil
|
||||
}
|
||||
limit, offset := mediaSearchPaging(req)
|
||||
limit, _ := mediaSearchPaging(req)
|
||||
|
||||
channel, member, err := s.getChannelForMember(ctx, s.db, viewerUserID, channelID)
|
||||
if err != nil {
|
||||
|
|
@ -281,28 +306,9 @@ func (s *ChannelStore) SearchChannelMedia(ctx context.Context, viewerUserID, cha
|
|||
if limit == 0 {
|
||||
return domain.ChannelHistory{Channel: channel, Self: member, Count: count}, nil
|
||||
}
|
||||
args := append([]any(nil), baseArgs...)
|
||||
if req.OffsetID > 0 {
|
||||
args = append(args, pgInt32NonNegative(req.OffsetID))
|
||||
base += fmt.Sprintf(" AND mi.id < $%d", len(args))
|
||||
}
|
||||
args = append(args, offset, limit)
|
||||
rows, err := s.db.Query(ctx, "SELECT DISTINCT mi.id"+base+
|
||||
fmt.Sprintf(" ORDER BY mi.id DESC OFFSET $%d LIMIT $%d", len(args)-1, len(args)), args...)
|
||||
ids, err := mediaSearchIDs(ctx, s.db, "mi.id", base, baseArgs, req)
|
||||
if err != nil {
|
||||
return domain.ChannelHistory{}, fmt.Errorf("list channel media ids: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := make([]int, 0, limit)
|
||||
for rows.Next() {
|
||||
var id int32
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return domain.ChannelHistory{}, fmt.Errorf("scan channel media id: %w", err)
|
||||
}
|
||||
ids = append(ids, int(id))
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return domain.ChannelHistory{}, fmt.Errorf("iterate channel media ids: %w", err)
|
||||
return domain.ChannelHistory{}, fmt.Errorf("list media ids: %w", err)
|
||||
}
|
||||
|
||||
hist, err := s.getChannelMessagesForMember(ctx, viewerUserID, channel, member, ids)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,335 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store"
|
||||
)
|
||||
|
||||
func externalReplyUsers(t *testing.T, pool *pgxpool.Pool) (domain.User, domain.User) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
users := NewUserStore(pool)
|
||||
suffix := randomSuffix(t)
|
||||
a := createTestUser(t, ctx, users, "+1813"+suffix+"01", "ExternalA", "")
|
||||
b := createTestUser(t, ctx, users, "+1813"+suffix+"02", "ExternalB", "")
|
||||
t.Logf("external reply fixture users: %d %d", a.ID, b.ID)
|
||||
t.Cleanup(func() {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
// Delete the owned projections before users: from_user_id is RESTRICT,
|
||||
// so user cascades alone depend on the database's trigger order.
|
||||
for _, query := range []string{
|
||||
"DELETE FROM dispatch_outbox WHERE target_user_id=ANY($1::bigint[])",
|
||||
"DELETE FROM user_update_events WHERE user_id=ANY($1::bigint[])",
|
||||
"DELETE FROM message_boxes WHERE owner_user_id=ANY($1::bigint[])",
|
||||
"DELETE FROM private_messages WHERE sender_user_id=ANY($1::bigint[])",
|
||||
"DELETE FROM dialogs WHERE user_id=ANY($1::bigint[])",
|
||||
"DELETE FROM users WHERE id=ANY($1::bigint[])",
|
||||
} {
|
||||
if _, err := tx.Exec(ctx, query, []int64{a.ID, b.ID}); err != nil {
|
||||
t.Error(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
t.Error(err)
|
||||
}
|
||||
})
|
||||
return a, b
|
||||
}
|
||||
|
||||
func TestPrivateExternalReplyAllReadPathsAndReplay(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
a, b := externalReplyUsers(t, pool)
|
||||
messages := newTestMessageStore(pool)
|
||||
source, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: a.ID, RandomID: 1, Message: "a🌕 quote", Date: 1700000000, Entities: []domain.MessageEntity{{Type: domain.MessageEntityBold, Offset: 4, Length: 5}}, Media: &domain.MessageMedia{Kind: domain.MessageMediaKindContact, Contact: &domain.MessageContact{FirstName: "source", PhoneNumber: "123"}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
collision, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: b.ID, RecipientUserID: b.ID, RandomID: 1, Message: "unrelated same ID", Date: 1700000000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if collision.SenderMessage.ID != source.SenderMessage.ID {
|
||||
t.Fatal("fixture must exercise owner-local ID collision")
|
||||
}
|
||||
req := domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: b.ID, RandomID: 2, Message: "external reply", Date: 1700000001, ReplyTo: &domain.MessageReply{Peer: domain.Peer{Type: domain.PeerTypeUser, ID: a.ID}, MessageID: source.SenderMessage.ID, QuoteText: "quote", QuoteOffset: 4}}
|
||||
res, err := messages.SendPrivateText(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want, err := domain.NewMessageReplyExternal(source.SenderMessage)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assertMessage := func(label string, m domain.Message) {
|
||||
t.Helper()
|
||||
if m.ReplyTo == nil || !reflect.DeepEqual(m.ReplyTo.External, want) || m.ReplyTo.QuoteText != "quote" || m.ReplyTo.QuoteOffset != 4 {
|
||||
t.Fatalf("%s lost snapshot: %+v", label, m.ReplyTo)
|
||||
}
|
||||
expectedID := source.SenderMessage.ID
|
||||
if m.OwnerUserID == b.ID {
|
||||
expectedID = 0
|
||||
}
|
||||
if m.ReplyTo.MessageID != expectedID {
|
||||
t.Fatalf("%s source ID=%d want=%d", label, m.ReplyTo.MessageID, expectedID)
|
||||
}
|
||||
}
|
||||
assertMessage("sender", res.SenderMessage)
|
||||
assertMessage("recipient", res.RecipientMessage)
|
||||
// Edit and delete the source using the normal transaction paths. Neither may
|
||||
// change the snapshot of a reply that has already committed.
|
||||
if _, err := messages.EditMessage(ctx, domain.EditMessageRequest{OwnerUserID: a.ID, Peer: source.SenderMessage.Peer, ID: source.SenderMessage.ID, Message: "changed source", EditDate: 1700000002}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{OwnerUserID: a.ID, IDs: []int{source.SenderMessage.ID}, Date: 1700000003}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, m := range []domain.Message{res.SenderMessage, res.RecipientMessage} {
|
||||
freshStore := newTestMessageStore(pool)
|
||||
got, found, err := freshStore.GetByUID(ctx, m.OwnerUserID, m.UID)
|
||||
if err != nil || !found {
|
||||
t.Fatalf("get uid: %v", err)
|
||||
}
|
||||
assertMessage("uid", got)
|
||||
list, err := freshStore.GetByIDs(ctx, m.OwnerUserID, []int{m.ID})
|
||||
if err != nil || len(list.Messages) != 1 {
|
||||
t.Fatalf("get ids: %v", err)
|
||||
}
|
||||
assertMessage("ids", list.Messages[0])
|
||||
list, err = freshStore.ListByUser(ctx, m.OwnerUserID, domain.MessageFilter{HasPeer: true, Peer: m.Peer, Limit: 10})
|
||||
if err != nil || len(list.Messages) != 1 {
|
||||
t.Fatalf("history: %v", err)
|
||||
}
|
||||
assertMessage("history", list.Messages[0])
|
||||
dialogs, err := NewDialogStore(pool).ListByUser(ctx, m.OwnerUserID, domain.DialogFilter{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found = false
|
||||
for _, top := range dialogs.Messages {
|
||||
if top.UID == m.UID {
|
||||
assertMessage("dialog", top)
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("dialog missing reply")
|
||||
}
|
||||
events := NewUpdateEventStore(pool)
|
||||
diff, err := events.ListAfter(ctx, m.OwnerUserID, m.Pts-1, 1)
|
||||
if err != nil || len(diff) != 1 {
|
||||
t.Fatalf("difference: %v", err)
|
||||
}
|
||||
assertMessage("difference", diff[0].Message)
|
||||
batch, err := events.BatchByCursor(ctx, []store.EventCursor{{UserID: m.OwnerUserID, Pts: m.Pts}})
|
||||
if err != nil || len(batch) != 1 {
|
||||
t.Fatalf("egress batch: %v", err)
|
||||
}
|
||||
assertMessage("egress", batch[0].Message)
|
||||
}
|
||||
// A preflight miss can race a prior commit. The lock-protected duplicate
|
||||
// lookup must win over revalidation of the now-deleted reply source.
|
||||
req.IdempotencyPreflighted = true
|
||||
replay, err := messages.SendPrivateText(ctx, req)
|
||||
if err != nil || !replay.Duplicate || replay.SenderMessage.ID != res.SenderMessage.ID {
|
||||
t.Fatalf("post-preflight replay: %+v err=%v", replay, err)
|
||||
}
|
||||
assertMessage("replay", replay.SenderMessage)
|
||||
req.RandomID = 3
|
||||
if _, err := messages.SendPrivateText(ctx, req); !errors.Is(err, domain.ErrReplyMessageIDInvalid) {
|
||||
t.Fatalf("fresh deleted reply: %v", err)
|
||||
}
|
||||
// Corruption must fail all reconstruction boundaries, never silently erase
|
||||
// the reply. The isolated test repairs its deliberately corrupted row.
|
||||
encoded, err := domain.EncodeMessageReplyExternal(want)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE message_boxes SET reply_external='{"unexpected":true}' WHERE private_message_id=$1`, res.SenderMessage.UID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, err := messages.GetByUID(ctx, b.ID, res.SenderMessage.UID); err == nil {
|
||||
t.Fatal("bad snapshot accepted by GetByUID")
|
||||
}
|
||||
if _, err := messages.GetByIDs(ctx, b.ID, []int{res.RecipientMessage.ID}); err == nil {
|
||||
t.Fatal("bad snapshot accepted by GetByIDs")
|
||||
}
|
||||
if _, err := NewUpdateEventStore(pool).BatchByCursor(ctx, []store.EventCursor{{UserID: b.ID, Pts: res.RecipientMessage.Pts}}); err == nil {
|
||||
t.Fatal("bad snapshot accepted by Egress")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE message_boxes SET reply_external=$2::jsonb WHERE private_message_id=$1`, res.SenderMessage.UID, encoded); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
down, err := os.ReadFile(filepath.Join("..", "..", "..", "deploy", "migrations", "20260908000002_private_external_reply.down.sql"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer tx.Rollback(ctx)
|
||||
if _, err := tx.Exec(ctx, string(down)); err == nil || !strings.Contains(err.Error(), "cannot discard durable external reply") {
|
||||
t.Fatalf("down migration lost snapshots: %v", err)
|
||||
}
|
||||
if err := tx.Rollback(ctx); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Once the sender deletes its output box, only the immutable receipt can
|
||||
// acknowledge a lost-response retry. Validate its nested external payload.
|
||||
if _, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{OwnerUserID: a.ID, IDs: []int{res.SenderMessage.ID}, Date: 1700000004}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.RandomID = 2
|
||||
replayed, err := messages.SendPrivateText(ctx, req)
|
||||
if err != nil || !replayed.Duplicate {
|
||||
t.Fatalf("replay after output delete: %v", err)
|
||||
}
|
||||
assertMessage("deleted-output replay", replayed.SenderMessage)
|
||||
var receipt []byte
|
||||
if err := pool.QueryRow(ctx, "SELECT sender_snapshot FROM private_messages WHERE sender_user_id=$1 AND id=$2", a.ID, res.SenderMessage.UID).Scan(&receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := pool.Exec(ctx, `UPDATE private_messages SET sender_snapshot=jsonb_set(sender_snapshot,'{message,ReplyTo,External,from,Date}','0') WHERE sender_user_id=$1 AND id=$2`, a.ID, res.SenderMessage.UID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := messages.SendPrivateText(ctx, req); err == nil {
|
||||
t.Fatal("corrupt deleted-output receipt replayed")
|
||||
}
|
||||
if _, err := pool.Exec(ctx, "UPDATE private_messages SET sender_snapshot=$3::jsonb WHERE sender_user_id=$1 AND id=$2", a.ID, res.SenderMessage.UID, receipt); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateExternalReplyProtectedPairAndQuoteRejectAtomically(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
a, b := externalReplyUsers(t, pool)
|
||||
messages := newTestMessageStore(pool)
|
||||
source, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: b.ID, RecipientUserID: a.ID, RandomID: 1, Message: "source 🌕 quote", Date: 1700000000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: a.ID, RandomID: 2, Message: "saved external", Date: 1700000001, ReplyTo: &domain.MessageReply{MessageID: source.RecipientMessage.ID, Peer: domain.Peer{Type: domain.PeerTypeUser, ID: b.ID}}}
|
||||
first, err := messages.SendPrivateText(ctx, req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if first.SenderMessage.ReplyTo == nil || first.SenderMessage.ReplyTo.External == nil || first.SenderMessage.ReplyTo.MessageID != source.RecipientMessage.ID {
|
||||
t.Fatal("private-to-Saved source missing")
|
||||
}
|
||||
saved, err := messages.ListSavedDialogs(ctx, a.ID, domain.SavedDialogsFilter{Limit: 10})
|
||||
if err != nil || len(saved.Messages) != 1 || !reflect.DeepEqual(saved.Messages[0].ReplyTo.External, first.SenderMessage.ReplyTo.External) {
|
||||
t.Fatalf("Saved dialog snapshot: %+v %v", saved, err)
|
||||
}
|
||||
if _, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{ActorUserID: a.ID, PeerUserID: b.ID, Enabled: true, RandomID: 3, Date: 1700000002}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
counts := func() [3]int64 {
|
||||
t.Helper()
|
||||
var out [3]int64
|
||||
err := pool.QueryRow(ctx, `SELECT (SELECT count(*) FROM private_messages WHERE sender_user_id=ANY($1::bigint[])),(SELECT count(*) FROM user_update_events WHERE user_id=ANY($1::bigint[])),(SELECT count(*) FROM dispatch_outbox WHERE target_user_id=ANY($1::bigint[]))`, []int64{a.ID, b.ID}).Scan(&out[0], &out[1], &out[2])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
before := counts()
|
||||
req.RandomID = 4
|
||||
if _, err := messages.SendPrivateText(ctx, req); !errors.Is(err, domain.ErrChatForwardsRestricted) {
|
||||
t.Fatalf("pair protection=%v", err)
|
||||
}
|
||||
if counts() != before {
|
||||
t.Fatal("rejected reply mutated durable state")
|
||||
}
|
||||
req.RandomID = 2
|
||||
req.IdempotencyPreflighted = true
|
||||
if replay, err := messages.SendPrivateText(ctx, req); err != nil || !replay.Duplicate {
|
||||
t.Fatalf("exact replay after protection=%v", err)
|
||||
}
|
||||
if counts() != before {
|
||||
t.Fatal("replay mutated state")
|
||||
}
|
||||
// Message protection still permits an ordinary same-dialog reply.
|
||||
same := domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: b.ID, RandomID: 5, Message: "same-dialog", Date: 1700000003, ReplyTo: &domain.MessageReply{MessageID: source.RecipientMessage.ID}}
|
||||
if reply, err := messages.SendPrivateText(ctx, same); err != nil || reply.SenderMessage.ReplyTo.External != nil {
|
||||
t.Fatalf("same-dialog=%v", err)
|
||||
}
|
||||
if _, err := messages.TogglePrivateNoForwards(ctx, domain.TogglePrivateNoForwardsRequest{ActorUserID: a.ID, PeerUserID: b.ID, Enabled: false, RandomID: 6, Date: 1700000004}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.RandomID = 7
|
||||
req.ReplyTo = domain.CloneMessageReply(req.ReplyTo)
|
||||
req.ReplyTo.QuoteText = "invented"
|
||||
before = counts()
|
||||
if _, err := messages.SendPrivateText(ctx, req); !errors.Is(err, domain.ErrQuoteTextInvalid) {
|
||||
t.Fatalf("invalid quote=%v", err)
|
||||
}
|
||||
if counts() != before {
|
||||
t.Fatal("invalid quote mutated durable state")
|
||||
}
|
||||
}
|
||||
|
||||
type replyBeginGate struct {
|
||||
*pgxpool.Pool
|
||||
entered, release chan struct{}
|
||||
}
|
||||
|
||||
func (g *replyBeginGate) Begin(ctx context.Context) (pgx.Tx, error) {
|
||||
close(g.entered)
|
||||
select {
|
||||
case <-g.release:
|
||||
return g.Pool.Begin(ctx)
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
func TestPrivateExternalReplySourceDeletedBeforeTransaction(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
a, b := externalReplyUsers(t, pool)
|
||||
messages := newTestMessageStore(pool)
|
||||
source, err := messages.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: a.ID, RandomID: 1, Message: "source", Date: 1700000000})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
gate := &replyBeginGate{Pool: pool, entered: make(chan struct{}), release: make(chan struct{})}
|
||||
blocked := newTestMessageStore(gate, WithMessageAllocators(testAllocatorsFor(pool).boxIDs))
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := blocked.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: a.ID, RecipientUserID: b.ID, RandomID: 2, Message: "race", Date: 1700000001, ReplyTo: &domain.MessageReply{MessageID: source.SenderMessage.ID, Peer: source.SenderMessage.Peer}})
|
||||
done <- err
|
||||
}()
|
||||
select {
|
||||
case <-gate.entered:
|
||||
case <-ctx.Done():
|
||||
t.Fatal(ctx.Err())
|
||||
}
|
||||
if _, err := messages.DeleteMessages(ctx, domain.DeleteMessagesRequest{OwnerUserID: a.ID, IDs: []int{source.SenderMessage.ID}}); err != nil {
|
||||
close(gate.release)
|
||||
t.Fatal(err)
|
||||
}
|
||||
close(gate.release)
|
||||
if err := <-done; !errors.Is(err, domain.ErrReplyMessageIDInvalid) {
|
||||
t.Fatalf("source deleted before BEGIN must reject: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -132,6 +132,7 @@ func messageFromForwardRow(row sqlcgen.GetMessageBoxesForForwardRow) (domain.Mes
|
|||
row.QuoteText,
|
||||
row.QuoteEntitiesJson,
|
||||
row.QuoteOffset,
|
||||
row.ReplyExternalJson,
|
||||
row.FwdFromPeerType,
|
||||
row.FwdFromPeerID,
|
||||
row.FwdFromName,
|
||||
|
|
|
|||
|
|
@ -12,12 +12,7 @@ import (
|
|||
)
|
||||
|
||||
func cloneMessageReply(reply *domain.MessageReply) *domain.MessageReply {
|
||||
if reply == nil {
|
||||
return nil
|
||||
}
|
||||
clone := *reply
|
||||
clone.QuoteEntities = append([]domain.MessageEntity(nil), reply.QuoteEntities...)
|
||||
return &clone
|
||||
return domain.CloneMessageReply(reply)
|
||||
}
|
||||
|
||||
func cloneChannelMessageAction(action *domain.ChannelMessageAction) *domain.ChannelMessageAction {
|
||||
|
|
@ -117,6 +112,7 @@ type messageMetadataParams struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJSON []byte
|
||||
QuoteOffset int32
|
||||
ReplyExternalJSON []byte
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -135,6 +131,7 @@ func messageMetadataParamsFrom(silent, noforwards bool, reply *domain.MessageRep
|
|||
Silent: silent,
|
||||
Noforwards: noforwards,
|
||||
QuoteEntitiesJSON: []byte("[]"),
|
||||
ReplyExternalJSON: []byte("{}"),
|
||||
}
|
||||
if reply != nil {
|
||||
if err := domain.ValidateMessageReplyBounds(reply); err != nil {
|
||||
|
|
@ -152,6 +149,10 @@ func messageMetadataParamsFrom(silent, noforwards bool, reply *domain.MessageRep
|
|||
meta.QuoteText = reply.QuoteText
|
||||
meta.QuoteEntitiesJSON = quoteEntities
|
||||
meta.QuoteOffset = int32(reply.QuoteOffset)
|
||||
meta.ReplyExternalJSON, err = domain.EncodeMessageReplyExternal(reply.External)
|
||||
if err != nil {
|
||||
return messageMetadataParams{}, err
|
||||
}
|
||||
}
|
||||
if forward != nil {
|
||||
if forward.Date < 0 {
|
||||
|
|
@ -170,9 +171,13 @@ func messageMetadataParamsFrom(silent, noforwards bool, reply *domain.MessageRep
|
|||
return meta, nil
|
||||
}
|
||||
|
||||
func messageMetadataFromFields(silent, noforwards bool, replyToMsgID int32, replyToPeerType string, replyToPeerID int64, replyToTopID int32, replyToStoryID int32, quoteText, quoteEntitiesJSON string, quoteOffset int32, fwdFromPeerType string, fwdFromPeerID int64, fwdFromName string, fwdDate int32, fwdSavedFromPeerType string, fwdSavedFromPeerID int64, fwdSavedFromMsgID int32) (bool, bool, *domain.MessageReply, *domain.MessageForward, error) {
|
||||
func messageMetadataFromFields(silent, noforwards bool, replyToMsgID int32, replyToPeerType string, replyToPeerID int64, replyToTopID int32, replyToStoryID int32, quoteText, quoteEntitiesJSON string, quoteOffset int32, replyExternalJSON string, fwdFromPeerType string, fwdFromPeerID int64, fwdFromName string, fwdDate int32, fwdSavedFromPeerType string, fwdSavedFromPeerID int64, fwdSavedFromMsgID int32) (bool, bool, *domain.MessageReply, *domain.MessageForward, error) {
|
||||
var reply *domain.MessageReply
|
||||
if replyToMsgID > 0 || replyToStoryID > 0 {
|
||||
external, err := domain.DecodeMessageReplyExternal([]byte(replyExternalJSON))
|
||||
if err != nil {
|
||||
return false, false, nil, nil, err
|
||||
}
|
||||
if replyToMsgID > 0 || replyToStoryID > 0 || external != nil {
|
||||
quoteEntities, err := decodeMessageEntities(quoteEntitiesJSON)
|
||||
if err != nil {
|
||||
return false, false, nil, nil, err
|
||||
|
|
@ -185,6 +190,7 @@ func messageMetadataFromFields(silent, noforwards bool, replyToMsgID int32, repl
|
|||
QuoteText: quoteText,
|
||||
QuoteEntities: quoteEntities,
|
||||
QuoteOffset: int(quoteOffset),
|
||||
External: external,
|
||||
}
|
||||
}
|
||||
var forward *domain.MessageForward
|
||||
|
|
|
|||
|
|
@ -73,10 +73,18 @@ func (s *MessageStore) GetByUID(ctx context.Context, userID, uid int64) (domain.
|
|||
if _, err := decodeReplyMarkup(row.ReplyMarkupJson); err != nil {
|
||||
return domain.Message{}, false, fmt.Errorf("get message by uid reply markup: %w", err)
|
||||
}
|
||||
return messageFromGetBoxRow(row), true, nil
|
||||
msg, err := messageFromGetBoxRow(row)
|
||||
return msg, err == nil, err
|
||||
}
|
||||
|
||||
func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (domain.MessageList, error) {
|
||||
if filter.CountOnly {
|
||||
total, err := s.countMessagesByUser(ctx, userID, filter)
|
||||
if err != nil {
|
||||
return domain.MessageList{}, fmt.Errorf("count messages: %w", err)
|
||||
}
|
||||
return domain.MessageList{Count: int(total)}, nil
|
||||
}
|
||||
limit := filter.Limit
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
|
|
@ -99,13 +107,14 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
|
|||
savedReactionKeys := postgresSavedReactionKeys(filter.SavedReactions)
|
||||
// add_offset>=0 是 backward 热路径(初始加载/上滑翻页,占 getHistory 绝大多数)。
|
||||
// 走扁平静态查询 ListMessagesBackward:规划仅单 index scan + 2 LEFT JOIN,避免
|
||||
// ListMessagesByUser 大 CTE 把 4 个分支+total 全树规划(6.7ms→~1ms)。与 CTE
|
||||
// ListMessagesByUser 大 CTE 把 4 个分支全树规划(6.7ms→~1ms)。与 CTE
|
||||
// 的 backward 分支逐位等价。around/forward(add_offset<0,锚点跳转,较罕见)仍走
|
||||
// 原 CTE,逻辑零改动。total 在 backward 路径由 CountMessagesByUser 独立提供。
|
||||
// CTE。total 始终由 CountMessagesByUser 独立提供,不依附消息行(空页仍有总数)。
|
||||
var rows []sqlcgen.ListMessagesByUserRow
|
||||
if addOffset >= 0 {
|
||||
bw, err := s.q.ListMessagesBackward(ctx, sqlcgen.ListMessagesBackwardParams{
|
||||
OwnerUserID: userID,
|
||||
SenderUserID: filter.SenderUserID,
|
||||
HasPeer: filter.HasPeer,
|
||||
PeerType: string(filter.Peer.Type),
|
||||
PeerID: filter.Peer.ID,
|
||||
|
|
@ -135,40 +144,12 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
|
|||
for i := range bw {
|
||||
rows[i] = backwardRowToByUserRow(bw[i])
|
||||
}
|
||||
if filter.NeedTotalCount {
|
||||
total, err := s.q.CountMessagesByUser(ctx, sqlcgen.CountMessagesByUserParams{
|
||||
OwnerUserID: userID,
|
||||
HasPeer: filter.HasPeer,
|
||||
PeerType: string(filter.Peer.Type),
|
||||
PeerID: filter.Peer.ID,
|
||||
RestrictPeerIds: filter.RestrictPeerIDs,
|
||||
PeerIds: filter.PeerIDs,
|
||||
Query: filter.Query,
|
||||
MinDate: pgInt32NonNegative(filter.MinDate),
|
||||
MaxDate: pgInt32NonNegative(filter.MaxDate),
|
||||
MaxID: pgInt32NonNegative(filter.MaxID),
|
||||
MinID: pgInt32NonNegative(filter.MinID),
|
||||
PinnedOnly: filter.PinnedOnly,
|
||||
MusicOnly: filter.MusicOnly,
|
||||
PhoneCallsOnly: filter.PhoneCallsOnly,
|
||||
MissedPhoneCallsOnly: filter.MissedPhoneCallsOnly,
|
||||
SavedPeerType: savedPeerType,
|
||||
SavedPeerID: savedPeerID,
|
||||
SavedReactionKeys: savedReactionKeys,
|
||||
})
|
||||
if err != nil {
|
||||
return domain.MessageList{}, fmt.Errorf("count messages: %w", err)
|
||||
}
|
||||
// 镜像原 CTE 的 CROSS JOIN total 语义:total_count 只随结果行下发,
|
||||
// paged 为空时不产出(out.Count 保持 0),故仅在有行时附着。
|
||||
for i := range rows {
|
||||
rows[i].TotalCount = total
|
||||
}
|
||||
}
|
||||
|
||||
} else {
|
||||
var err error
|
||||
rows, err = s.q.ListMessagesByUser(ctx, sqlcgen.ListMessagesByUserParams{
|
||||
OwnerUserID: userID,
|
||||
SenderUserID: filter.SenderUserID,
|
||||
HasPeer: filter.HasPeer,
|
||||
PeerType: string(filter.Peer.Type),
|
||||
PeerID: filter.Peer.ID,
|
||||
|
|
@ -187,7 +168,6 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
|
|||
MusicOnly: filter.MusicOnly,
|
||||
PhoneCallsOnly: filter.PhoneCallsOnly,
|
||||
MissedPhoneCallsOnly: filter.MissedPhoneCallsOnly,
|
||||
NeedTotalCount: filter.NeedTotalCount,
|
||||
SavedPeerType: savedPeerType,
|
||||
SavedPeerID: savedPeerID,
|
||||
SavedReactionKeys: savedReactionKeys,
|
||||
|
|
@ -222,6 +202,7 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
|
|||
row.QuoteText,
|
||||
row.QuoteEntitiesJson,
|
||||
row.QuoteOffset,
|
||||
row.ReplyExternalJson,
|
||||
row.FwdFromPeerType,
|
||||
row.FwdFromPeerID,
|
||||
row.FwdFromName,
|
||||
|
|
@ -275,12 +256,15 @@ func (s *MessageStore) ListByUser(ctx context.Context, userID int64, filter doma
|
|||
Pinned: row.Pinned,
|
||||
SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID),
|
||||
})
|
||||
if filter.NeedTotalCount && out.Count == 0 {
|
||||
out.Count = int(row.TotalCount)
|
||||
}
|
||||
appendUserFromMessageRow(&out, seenUsers, row)
|
||||
}
|
||||
if !filter.NeedTotalCount {
|
||||
if filter.NeedTotalCount {
|
||||
total, err := s.countMessagesByUser(ctx, userID, filter)
|
||||
if err != nil {
|
||||
return domain.MessageList{}, fmt.Errorf("count messages: %w", err)
|
||||
}
|
||||
out.Count = int(total)
|
||||
} else {
|
||||
out.Count = len(out.Messages)
|
||||
if hasMore {
|
||||
out.Count++
|
||||
|
|
@ -666,12 +650,24 @@ func (s *MessageStore) DeleteHistory(ctx context.Context, req domain.DeleteHisto
|
|||
return res, nil
|
||||
}
|
||||
|
||||
func messageFromBoxRow(row sqlcgen.CreateMessageBoxRow) domain.Message {
|
||||
entities, _ := decodeMessageEntities(row.EntitiesJson)
|
||||
media, _ := decodeMessageMedia(row.MediaJson)
|
||||
markup, _ := decodeReplyMarkup(row.ReplyMarkupJson)
|
||||
rich, _ := decodeRichMessage(row.RichMessageJson)
|
||||
silent, noforwards, reply, forward, _ := messageMetadataFromFields(
|
||||
func messageFromBoxRow(row sqlcgen.CreateMessageBoxRow) (domain.Message, error) {
|
||||
entities, err := decodeMessageEntities(row.EntitiesJson)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
media, err := decodeMessageMedia(row.MediaJson)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
markup, err := decodeReplyMarkup(row.ReplyMarkupJson)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
rich, err := decodeRichMessage(row.RichMessageJson)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
silent, noforwards, reply, forward, err := messageMetadataFromFields(
|
||||
row.Silent,
|
||||
row.Noforwards,
|
||||
row.ReplyToMsgID,
|
||||
|
|
@ -682,6 +678,7 @@ func messageFromBoxRow(row sqlcgen.CreateMessageBoxRow) domain.Message {
|
|||
row.QuoteText,
|
||||
row.QuoteEntitiesJson,
|
||||
row.QuoteOffset,
|
||||
row.ReplyExternalJson,
|
||||
row.FwdFromPeerType,
|
||||
row.FwdFromPeerID,
|
||||
row.FwdFromName,
|
||||
|
|
@ -690,6 +687,9 @@ func messageFromBoxRow(row sqlcgen.CreateMessageBoxRow) domain.Message {
|
|||
row.FwdSavedFromPeerID,
|
||||
row.FwdSavedFromMsgID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
return domain.Message{
|
||||
Media: media,
|
||||
ReplyMarkup: markup,
|
||||
|
|
@ -719,15 +719,27 @@ func messageFromBoxRow(row sqlcgen.CreateMessageBoxRow) domain.Message {
|
|||
Effect: row.Effect,
|
||||
Pinned: row.Pinned,
|
||||
SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID),
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func messageFromGetBoxRow(row sqlcgen.GetMessageBoxByPrivateMessageRow) domain.Message {
|
||||
entities, _ := decodeMessageEntities(row.EntitiesJson)
|
||||
media, _ := decodeMessageMedia(row.MediaJson)
|
||||
markup, _ := decodeReplyMarkup(row.ReplyMarkupJson)
|
||||
rich, _ := decodeRichMessage(row.RichMessageJson)
|
||||
silent, noforwards, reply, forward, _ := messageMetadataFromFields(
|
||||
func messageFromGetBoxRow(row sqlcgen.GetMessageBoxByPrivateMessageRow) (domain.Message, error) {
|
||||
entities, err := decodeMessageEntities(row.EntitiesJson)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
media, err := decodeMessageMedia(row.MediaJson)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
markup, err := decodeReplyMarkup(row.ReplyMarkupJson)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
rich, err := decodeRichMessage(row.RichMessageJson)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
silent, noforwards, reply, forward, err := messageMetadataFromFields(
|
||||
row.Silent,
|
||||
row.Noforwards,
|
||||
row.ReplyToMsgID,
|
||||
|
|
@ -738,6 +750,7 @@ func messageFromGetBoxRow(row sqlcgen.GetMessageBoxByPrivateMessageRow) domain.M
|
|||
row.QuoteText,
|
||||
row.QuoteEntitiesJson,
|
||||
row.QuoteOffset,
|
||||
row.ReplyExternalJson,
|
||||
row.FwdFromPeerType,
|
||||
row.FwdFromPeerID,
|
||||
row.FwdFromName,
|
||||
|
|
@ -746,6 +759,9 @@ func messageFromGetBoxRow(row sqlcgen.GetMessageBoxByPrivateMessageRow) domain.M
|
|||
row.FwdSavedFromPeerID,
|
||||
row.FwdSavedFromMsgID,
|
||||
)
|
||||
if err != nil {
|
||||
return domain.Message{}, err
|
||||
}
|
||||
return domain.Message{
|
||||
Media: media,
|
||||
ReplyMarkup: markup,
|
||||
|
|
@ -775,7 +791,7 @@ func messageFromGetBoxRow(row sqlcgen.GetMessageBoxByPrivateMessageRow) domain.M
|
|||
Effect: row.Effect,
|
||||
Pinned: row.Pinned,
|
||||
SavedPeer: savedPeerFromFields(row.SavedPeerType, row.SavedPeerID),
|
||||
}
|
||||
}, nil
|
||||
}
|
||||
|
||||
func messageFromVisibleBoxRow(row sqlcgen.ListVisibleMessageBoxesByPrivateMessageRow) (domain.Message, error) {
|
||||
|
|
@ -794,6 +810,7 @@ func messageFromVisibleBoxRow(row sqlcgen.ListVisibleMessageBoxesByPrivateMessag
|
|||
row.QuoteText,
|
||||
row.QuoteEntitiesJson,
|
||||
row.QuoteOffset,
|
||||
row.ReplyExternalJson,
|
||||
row.FwdFromPeerType,
|
||||
row.FwdFromPeerID,
|
||||
row.FwdFromName,
|
||||
|
|
@ -865,6 +882,7 @@ func messageFromUpdateEditRow(row sqlcgen.UpdateMessageBoxEditRow) (domain.Messa
|
|||
row.QuoteText,
|
||||
row.QuoteEntitiesJson,
|
||||
row.QuoteOffset,
|
||||
row.ReplyExternalJson,
|
||||
row.FwdFromPeerType,
|
||||
row.FwdFromPeerID,
|
||||
row.FwdFromName,
|
||||
|
|
@ -936,6 +954,7 @@ func messageFromIDRow(row sqlcgen.GetMessageBoxesByIDsRow) (domain.Message, erro
|
|||
row.QuoteText,
|
||||
row.QuoteEntitiesJson,
|
||||
row.QuoteOffset,
|
||||
row.ReplyExternalJson,
|
||||
row.FwdFromPeerType,
|
||||
row.FwdFromPeerID,
|
||||
row.FwdFromName,
|
||||
|
|
@ -1004,7 +1023,7 @@ func eventFromMessage(msg domain.Message) domain.UpdateEvent {
|
|||
|
||||
// backwardRowToByUserRow 把 ListMessagesBackward(扁平 backward 热路径)的行适配为
|
||||
// ListMessagesByUserRow,从而复用 ListByUser 既有的解码/用户收集下游逻辑。两者列与
|
||||
// base 完全一致,仅缺 TotalCount(由调用方按 NeedTotalCount 单独填,默认 0)。
|
||||
// base 完全一致;总数由调用方在 NeedTotalCount 时独立查询。
|
||||
func backwardRowToByUserRow(r sqlcgen.ListMessagesBackwardRow) sqlcgen.ListMessagesByUserRow {
|
||||
return sqlcgen.ListMessagesByUserRow{
|
||||
BoxID: r.BoxID,
|
||||
|
|
@ -1031,6 +1050,7 @@ func backwardRowToByUserRow(r sqlcgen.ListMessagesBackwardRow) sqlcgen.ListMessa
|
|||
QuoteText: r.QuoteText,
|
||||
QuoteEntitiesJson: r.QuoteEntitiesJson,
|
||||
QuoteOffset: r.QuoteOffset,
|
||||
ReplyExternalJson: r.ReplyExternalJson,
|
||||
FwdFromPeerType: r.FwdFromPeerType,
|
||||
FwdFromPeerID: r.FwdFromPeerID,
|
||||
FwdFromName: r.FwdFromName,
|
||||
|
|
@ -1176,3 +1196,35 @@ func appendMessageUsers(out *domain.MessageList, seen map[int64]struct{}, users
|
|||
add(user)
|
||||
}
|
||||
}
|
||||
|
||||
// countMessagesByUser shares page predicates but intentionally excludes page anchors.
|
||||
func (s *MessageStore) countMessagesByUser(ctx context.Context, userID int64, filter domain.MessageFilter) (int32, error) {
|
||||
savedPeerType := ""
|
||||
var savedPeerID int64
|
||||
if filter.SavedPeer.ID != 0 {
|
||||
savedPeerType = string(filter.SavedPeer.Type)
|
||||
savedPeerID = filter.SavedPeer.ID
|
||||
}
|
||||
savedReactionKeys := postgresSavedReactionKeys(filter.SavedReactions)
|
||||
return s.q.CountMessagesByUser(ctx, sqlcgen.CountMessagesByUserParams{
|
||||
SenderUserID: filter.SenderUserID,
|
||||
OwnerUserID: userID,
|
||||
HasPeer: filter.HasPeer,
|
||||
PeerType: string(filter.Peer.Type),
|
||||
PeerID: filter.Peer.ID,
|
||||
RestrictPeerIds: filter.RestrictPeerIDs,
|
||||
PeerIds: filter.PeerIDs,
|
||||
Query: filter.Query,
|
||||
MinDate: pgInt32NonNegative(filter.MinDate),
|
||||
MaxDate: pgInt32NonNegative(filter.MaxDate),
|
||||
MaxID: pgInt32NonNegative(filter.MaxID),
|
||||
MinID: pgInt32NonNegative(filter.MinID),
|
||||
PinnedOnly: filter.PinnedOnly,
|
||||
MusicOnly: filter.MusicOnly,
|
||||
PhoneCallsOnly: filter.PhoneCallsOnly,
|
||||
MissedPhoneCallsOnly: filter.MissedPhoneCallsOnly,
|
||||
SavedPeerType: savedPeerType,
|
||||
SavedPeerID: savedPeerID,
|
||||
SavedReactionKeys: savedReactionKeys,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,7 +57,7 @@ func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing
|
|||
if err != nil {
|
||||
t.Fatalf("around history: %v", err)
|
||||
}
|
||||
if got := messageIDs(around.Messages); !sameInts(got, []int{6, 5, 4, 3, 2, 1}) {
|
||||
if got := messageIDs(around.Messages); !sameInts(got, []int{5, 4, 3, 2, 1}) {
|
||||
t.Fatalf("around ids = %v, want unread/newer side plus older context", got)
|
||||
}
|
||||
if around.Count != 6 {
|
||||
|
|
@ -75,8 +75,8 @@ func TestMessageStoreListByUserSupportsForwardAndAroundHistoryOffsets(t *testing
|
|||
if err != nil {
|
||||
t.Fatalf("forward history: %v", err)
|
||||
}
|
||||
if got := messageIDs(forward.Messages); !sameInts(got, []int{6, 5, 4}) {
|
||||
t.Fatalf("forward ids = %v, want messages newer than offset", got)
|
||||
if got := messageIDs(forward.Messages); !sameInts(got, []int{5, 4, 3}) {
|
||||
t.Fatalf("forward ids = %v, want forward window including offset anchor", got)
|
||||
}
|
||||
if forward.Count != 6 {
|
||||
t.Fatalf("forward count = %d, want full dialog count", forward.Count)
|
||||
|
|
|
|||
|
|
@ -398,7 +398,11 @@ func (s *MessageStore) ListUnreadReactionMessages(ctx context.Context, ownerUser
|
|||
}
|
||||
out := make([]domain.Message, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
out = append(out, messageFromBoxRow(sqlcgen.CreateMessageBoxRow(row)))
|
||||
msg, err := messageFromBoxRow(sqlcgen.CreateMessageBoxRow(row))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, msg)
|
||||
}
|
||||
if err := s.enrichPrivateMessageReactions(ctx, s.db, ownerUserID, out); err != nil {
|
||||
return nil, err
|
||||
|
|
|
|||
56
internal/store/postgres/message_search_count_test.go
Normal file
56
internal/store/postgres/message_search_count_test.go
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"github.com/jackc/pgx/v5"
|
||||
"strings"
|
||||
"telesrv/internal/domain"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// A DB boundary that only accepts the count query. Embedded unexpected methods
|
||||
// panic, so a page fetch, enrichment query or mutation cannot pass this test.
|
||||
type countReadDB struct {
|
||||
pgx.Tx
|
||||
calls int
|
||||
result int32
|
||||
err error
|
||||
}
|
||||
|
||||
func (d *countReadDB) QueryRow(_ context.Context, q string, args ...any) pgx.Row {
|
||||
if !strings.HasPrefix(q, "-- name: CountMessagesByUser :one") {
|
||||
panic("unexpected query")
|
||||
}
|
||||
d.calls++
|
||||
return countReadRow{d.result, d.err}
|
||||
}
|
||||
|
||||
type countReadRow struct {
|
||||
value int32
|
||||
err error
|
||||
}
|
||||
|
||||
func (r countReadRow) Scan(dest ...any) error {
|
||||
if r.err != nil {
|
||||
return r.err
|
||||
}
|
||||
*dest[0].(*int32) = r.value
|
||||
return nil
|
||||
}
|
||||
func TestMessageSearchCountOnlySkipsPageAndHydration(t *testing.T) {
|
||||
for _, count := range []int32{0, 7} {
|
||||
db := &countReadDB{result: count}
|
||||
s := NewMessageStore(db)
|
||||
got, err := s.ListByUser(context.Background(), 1, domain.MessageFilter{CountOnly: true, SenderUserID: 2, OffsetID: 8, AddOffset: -3, Limit: 500})
|
||||
if err != nil || got.Count != int(count) || len(got.Messages) != 0 || len(got.Users) != 0 || db.calls != 1 {
|
||||
t.Fatalf("result=%+v err=%v calls=%d", got, err, db.calls)
|
||||
}
|
||||
}
|
||||
failure := errors.New("database unavailable")
|
||||
db := &countReadDB{err: failure}
|
||||
got, err := NewMessageStore(db).ListByUser(context.Background(), 1, domain.MessageFilter{CountOnly: true})
|
||||
if !errors.Is(err, failure) || got.Count != 0 || db.calls != 1 {
|
||||
t.Fatalf("error swallowed: result=%+v err=%v", got, err)
|
||||
}
|
||||
}
|
||||
80
internal/store/postgres/message_search_integration_test.go
Normal file
80
internal/store/postgres/message_search_integration_test.go
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"telesrv/internal/domain"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestMessageSearchSenderAndCountPredicates(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
users := NewUserStore(pool)
|
||||
suffix := randomSuffix(t)
|
||||
a, err := users.Create(ctx, domain.User{AccessHash: 61, Phone: "+1669" + suffix + "01", FirstName: "Search A"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := users.Create(ctx, domain.User{AccessHash: 62, Phone: "+1669" + suffix + "02", FirstName: "Search B"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = pool.Exec(ctx, "DELETE FROM users WHERE id=ANY($1::bigint[])", []int64{a.ID, b.ID}) })
|
||||
s := newTestMessageStore(pool)
|
||||
for i := 1; i <= 6; i++ {
|
||||
sender, recipient := a.ID, b.ID
|
||||
if i%2 == 0 {
|
||||
sender, recipient = recipient, sender
|
||||
}
|
||||
_, err = s.SendPrivateText(ctx, domain.SendPrivateTextRequest{SenderUserID: sender, RecipientUserID: recipient, RandomID: int64(9100 + i), Message: "count needle", Date: 1700000000 + i})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
peer := domain.Peer{Type: domain.PeerTypeUser, ID: b.ID}
|
||||
_, err = s.PinPrivateMessage(ctx, domain.PinPrivateMessageRequest{OwnerUserID: a.ID, Peer: peer, MessageID: 3, Pinned: true, PmOneside: true, Date: 1700000010})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
f domain.MessageFilter
|
||||
ids []int
|
||||
count int
|
||||
}{
|
||||
{"sender-backward", domain.MessageFilter{SenderUserID: a.ID, Limit: 10, NeedTotalCount: true}, []int{5, 3, 1}, 3},
|
||||
{"sender-around", domain.MessageFilter{SenderUserID: a.ID, OffsetID: 3, AddOffset: -1, Limit: 3, NeedTotalCount: true}, []int{3, 1}, 3},
|
||||
{"sender-forward", domain.MessageFilter{SenderUserID: b.ID, OffsetID: 2, AddOffset: -2, Limit: 2, NeedTotalCount: true}, []int{4, 2}, 3},
|
||||
{"count-ignores-page", domain.MessageFilter{SenderUserID: a.ID, CountOnly: true, OffsetID: 1, AddOffset: 100}, nil, 3},
|
||||
{"count-dates-ids", domain.MessageFilter{SenderUserID: a.ID, CountOnly: true, MinDate: 1700000001, MaxDate: 1700000006, MinID: 1, MaxID: 5}, nil, 1},
|
||||
{"empty-backward-total", domain.MessageFilter{SenderUserID: a.ID, AddOffset: 100, Limit: 2, NeedTotalCount: true}, nil, 3},
|
||||
{"empty-forward-total", domain.MessageFilter{SenderUserID: a.ID, OffsetID: 6, AddOffset: -2, Limit: 2, NeedTotalCount: true}, nil, 3},
|
||||
{"count-empty", domain.MessageFilter{SenderUserID: a.ID, CountOnly: true, MinID: 5}, nil, 0},
|
||||
{"count-pinned-own", domain.MessageFilter{SenderUserID: a.ID, CountOnly: true, PinnedOnly: true}, nil, 1},
|
||||
{"count-pinned-other", domain.MessageFilter{SenderUserID: b.ID, CountOnly: true, PinnedOnly: true}, nil, 0},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := tt.f
|
||||
f.HasPeer = true
|
||||
f.Peer = peer
|
||||
f.Query = "needle"
|
||||
got, err := s.ListByUser(ctx, a.ID, f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := messageIDs(got.Messages)
|
||||
if len(ids) != len(tt.ids) || (len(ids) > 0 && !reflect.DeepEqual(ids, tt.ids)) || got.Count != tt.count {
|
||||
t.Fatalf("ids=%v count=%d want %v/%d", ids, got.Count, tt.ids, tt.count)
|
||||
}
|
||||
if f.CountOnly && len(got.Users) != 0 {
|
||||
t.Fatalf("count hydrated users: %+v", got.Users)
|
||||
}
|
||||
})
|
||||
}
|
||||
// The opposite owner cannot observe a one-sided pin through a global count.
|
||||
got, err := s.ListByUser(ctx, b.ID, domain.MessageFilter{CountOnly: true, PinnedOnly: true, SenderUserID: a.ID, Query: "needle"})
|
||||
if err != nil || got.Count != 0 {
|
||||
t.Fatalf("owner isolation %+v %v", got, err)
|
||||
}
|
||||
}
|
||||
|
|
@ -223,18 +223,6 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
return domain.SendPrivateTextResult{}, fmt.Errorf("wait private send actor lanes: %w", err)
|
||||
}
|
||||
defer releaseLanes()
|
||||
senderReply, recipientReply, err := s.resolvePrivateSendReply(ctx, req)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
senderMeta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, senderReply, req.Forward)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
recipientMeta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, recipientReply, req.Forward)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
beginner, ok := s.db.(txBeginner)
|
||||
if !ok {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("send private text: db does not support transactions")
|
||||
|
|
@ -243,11 +231,6 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
var senderBoxID, recipientBoxID, recipientPts int
|
||||
selfMessage := req.RecipientUserID == req.SenderUserID
|
||||
deliverRecipient := !selfMessage && !req.RecipientBlocked
|
||||
if selfMessage {
|
||||
savedPeer := domain.SavedPeerForSelfChat(req.SenderUserID, req.Forward)
|
||||
senderMeta.SavedPeerType = string(savedPeer.Type)
|
||||
senderMeta.SavedPeerID = savedPeer.ID
|
||||
}
|
||||
// Box ids allow gaps. Allocate them before borrowing a PostgreSQL connection
|
||||
// so Redis latency never extends the database transaction's lock lifetime.
|
||||
if plainHotPath {
|
||||
|
|
@ -284,6 +267,36 @@ func (s *MessageStore) sendPrivateTextOnce(ctx context.Context, req domain.SendP
|
|||
if err := lockDispatchOutboxAppendFences(ctx, tx, []int64{req.SenderUserID, req.RecipientUserID}); err != nil {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("lock send dispatch append fences: %w", err)
|
||||
}
|
||||
if hooks.before != nil || req.ReplyTo != nil {
|
||||
// The preflight above cannot observe another first request until it commits.
|
||||
// Recheck after the per-user transaction lock so aggregate-backed sends
|
||||
// replay the committed message before their hook runs a second time, and
|
||||
// so an external-reply source deleted between the preflight and here is
|
||||
// resolved against the committed state, not a stale read.
|
||||
if duplicate, found, err := s.duplicateSendResult(ctx, qtx, req, requestFingerprint); err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
} else if found {
|
||||
duplicate.Duplicate = true
|
||||
return duplicate, nil
|
||||
}
|
||||
}
|
||||
senderReply, recipientReply, err := s.resolvePrivateSendReply(ctx, tx, qtx, req)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
senderMeta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, senderReply, req.Forward)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
recipientMeta, err := messageMetadataParamsFrom(req.Silent, req.NoForwards, recipientReply, req.Forward)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
}
|
||||
if selfMessage {
|
||||
savedPeer := domain.SavedPeerForSelfChat(req.SenderUserID, req.Forward)
|
||||
senderMeta.SavedPeerType = string(savedPeer.Type)
|
||||
senderMeta.SavedPeerID = savedPeer.ID
|
||||
}
|
||||
if hooks.before != nil {
|
||||
if err := hooks.before(ctx, tx, &req); err != nil {
|
||||
return domain.SendPrivateTextResult{}, err
|
||||
|
|
@ -486,7 +499,10 @@ WHERE sender_user_id=$1 AND id=$2`, req.SenderUserID, pm.ID, sharedMediaJSON)
|
|||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("create sender box: %w", err)
|
||||
}
|
||||
sender := messageFromBoxRow(senderRow)
|
||||
sender, err := messageFromBoxRow(senderRow)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("decode sender box: %w", err)
|
||||
}
|
||||
sender.RandomID = req.RandomID
|
||||
// 共享媒体索引(0118):发送者侧 box 按媒体类别建索引(peer=收件人)。
|
||||
if err := insertMessageBoxMediaIndexTx(ctx, tx, req.SenderUserID, req.RecipientUserID, int(senderBoxID), req.Date, media.Sender, req.Entities); err != nil {
|
||||
|
|
@ -557,7 +573,10 @@ WHERE sender_user_id=$1 AND id=$2`, req.SenderUserID, pm.ID, sharedMediaJSON)
|
|||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("create recipient box: %w", err)
|
||||
}
|
||||
recipient = messageFromBoxRow(recipientRow)
|
||||
recipient, err = messageFromBoxRow(recipientRow)
|
||||
if err != nil {
|
||||
return domain.SendPrivateTextResult{}, fmt.Errorf("decode recipient box: %w", err)
|
||||
}
|
||||
recipient.RandomID = req.RandomID
|
||||
// 共享媒体索引(0118):收件人侧 box 按媒体类别建索引(peer=发送者)。
|
||||
if err := insertMessageBoxMediaIndexTx(ctx, tx, req.RecipientUserID, req.SenderUserID, int(recipientBoxID), req.Date, media.Recipient, req.Entities); err != nil {
|
||||
|
|
@ -727,7 +746,10 @@ func (s *MessageStore) duplicateSendResult(ctx context.Context, q *sqlcgen.Queri
|
|||
PrivateMessageID: pm.ID,
|
||||
})
|
||||
if currentErr == nil {
|
||||
sender = messageFromGetBoxRow(currentRow)
|
||||
sender, currentErr = messageFromGetBoxRow(currentRow)
|
||||
if currentErr != nil {
|
||||
return domain.SendPrivateTextResult{}, false, fmt.Errorf("decode duplicate private message %d current sender box: %w", pm.ID, currentErr)
|
||||
}
|
||||
sender.RandomID = pm.RandomID
|
||||
} else if !errors.Is(currentErr, pgx.ErrNoRows) {
|
||||
return domain.SendPrivateTextResult{}, false, fmt.Errorf("get current duplicate private message %d sender box: %w", pm.ID, currentErr)
|
||||
|
|
@ -783,10 +805,16 @@ func (s *MessageStore) duplicateSendResult(ctx context.Context, q *sqlcgen.Queri
|
|||
}, true, nil
|
||||
}
|
||||
|
||||
func (s *MessageStore) resolvePrivateSendReply(ctx context.Context, req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) {
|
||||
func (s *MessageStore) resolvePrivateSendReply(ctx context.Context, db sqlcgen.DBTX, q *sqlcgen.Queries, req domain.SendPrivateTextRequest) (*domain.MessageReply, *domain.MessageReply, error) {
|
||||
if req.ReplyTo == nil {
|
||||
return nil, nil, nil
|
||||
}
|
||||
if req.ReplyTo.External != nil {
|
||||
// External is a server-computed snapshot (see below); a client sending
|
||||
// one is either a replay of our own wire encoding sent back to us, or a
|
||||
// forged value, neither of which should be trusted as-is.
|
||||
return nil, nil, domain.ErrReplyMessageIDInvalid
|
||||
}
|
||||
if req.ReplyTo.StoryID > 0 {
|
||||
// story 回复(评论):无源消息可查;story 作者就是会话对端(recipient),双盒同持。
|
||||
if req.ReplyTo.StoryID > domain.MaxStoryID {
|
||||
|
|
@ -816,7 +844,7 @@ func (s *MessageStore) resolvePrivateSendReply(ctx context.Context, req domain.S
|
|||
reply.Peer = peer
|
||||
return reply, cloneMessageReply(reply), nil
|
||||
}
|
||||
source, err := s.q.GetMessageBoxForReply(ctx, sqlcgen.GetMessageBoxForReplyParams{
|
||||
source, err := q.GetMessageBoxForReply(ctx, sqlcgen.GetMessageBoxForReplyParams{
|
||||
OwnerUserID: req.SenderUserID,
|
||||
PeerType: string(peer.Type),
|
||||
PeerID: peer.ID,
|
||||
|
|
@ -831,17 +859,49 @@ func (s *MessageStore) resolvePrivateSendReply(ctx context.Context, req domain.S
|
|||
senderReply := cloneMessageReply(req.ReplyTo)
|
||||
senderReply.MessageID = int(source.BoxID)
|
||||
senderReply.Peer = peer
|
||||
if peer.ID != req.RecipientUserID {
|
||||
// A cross-dialog reply references the sender's source box, which has no
|
||||
// corresponding row in the destination dialog to remap to. Both sides
|
||||
// therefore carry an immutable External snapshot of the source instead
|
||||
// of a live, re-resolvable message reference.
|
||||
entities, err := decodeMessageEntities(source.EntitiesJson)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
media, err := decodeMessageMedia(source.MediaJson)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
protected := source.Noforwards || (media != nil && media.TTLSeconds > 0)
|
||||
if low, high, ok := pgNoForwardsPair(req.SenderUserID, peer.ID); ok {
|
||||
var pairProtected bool
|
||||
if err := db.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM private_no_forwards_chats WHERE user_low_id=$1 AND user_high_id=$2 AND COALESCE(enabled_by_user_id,0)<>0)`, low, high).Scan(&pairProtected); err != nil {
|
||||
return nil, nil, fmt.Errorf("read reply source protection: %w", err)
|
||||
}
|
||||
protected = protected || pairProtected
|
||||
}
|
||||
if protected {
|
||||
return nil, nil, domain.ErrChatForwardsRestricted
|
||||
}
|
||||
if err := domain.ValidateExternalReplyQuote(req.ReplyTo, source.Body); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
senderReply.External, err = domain.NewMessageReplyExternal(domain.Message{From: domain.Peer{Type: domain.PeerTypeUser, ID: source.FromUserID}, Date: int(source.MessageDate), Body: source.Body, Entities: entities, Media: media})
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
recipientReply := cloneMessageReply(senderReply)
|
||||
if req.SenderUserID != req.RecipientUserID {
|
||||
recipientReply.MessageID = 0
|
||||
recipientReply.TopMessageID = 0
|
||||
}
|
||||
return senderReply, recipientReply, nil
|
||||
}
|
||||
if req.SenderUserID == req.RecipientUserID {
|
||||
return senderReply, cloneMessageReply(senderReply), nil
|
||||
}
|
||||
if peer.ID != req.RecipientUserID {
|
||||
// A cross-dialog reply references the sender's source box. There is no
|
||||
// corresponding row in the destination dialog to remap to; both sides
|
||||
// therefore receive the explicit source peer/message pair.
|
||||
return senderReply, cloneMessageReply(senderReply), nil
|
||||
}
|
||||
|
||||
recipientRow, err := s.q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{
|
||||
recipientRow, err := q.GetMessageBoxByPrivateMessage(ctx, sqlcgen.GetMessageBoxByPrivateMessageParams{
|
||||
OwnerUserID: req.RecipientUserID,
|
||||
PrivateMessageID: source.PrivateMessageID,
|
||||
})
|
||||
|
|
@ -931,6 +991,7 @@ func applyCreatePrivateMessageMetadata(arg *sqlcgen.CreatePrivateMessageParams,
|
|||
arg.QuoteText = meta.QuoteText
|
||||
arg.QuoteEntitiesJson = meta.QuoteEntitiesJSON
|
||||
arg.QuoteOffset = meta.QuoteOffset
|
||||
arg.ReplyExternalJson = meta.ReplyExternalJSON
|
||||
arg.FwdFromPeerType = meta.FwdFromPeerType
|
||||
arg.FwdFromPeerID = meta.FwdFromPeerID
|
||||
arg.FwdFromName = meta.FwdFromName
|
||||
|
|
@ -948,6 +1009,7 @@ func applyCreateMessageBoxMetadata(arg *sqlcgen.CreateMessageBoxParams, meta mes
|
|||
arg.QuoteText = meta.QuoteText
|
||||
arg.QuoteEntitiesJson = meta.QuoteEntitiesJSON
|
||||
arg.QuoteOffset = meta.QuoteOffset
|
||||
arg.ReplyExternalJson = meta.ReplyExternalJSON
|
||||
arg.FwdFromPeerType = meta.FwdFromPeerType
|
||||
arg.FwdFromPeerID = meta.FwdFromPeerID
|
||||
arg.FwdFromName = meta.FwdFromName
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
|
@ -246,27 +247,46 @@ func TestMessageStorePrivateRandomIDReplayUsesCurrentSnapshotAndDurableDelete(t
|
|||
}
|
||||
}
|
||||
|
||||
// beginHookDB lets the test commit a competing send exactly after the outer
|
||||
// fast-path lookup and before its transaction starts. With MaxConns=1, a
|
||||
// privatePreflightHookDB commits a competing send after the fast-path lookup
|
||||
// releases its connection but before account admission. Running it in Begin
|
||||
// would nest a send inside the same process's already-held account lease.
|
||||
// With MaxConns=1, a
|
||||
// duplicate fallback that queries the pool while holding the transaction would
|
||||
// wait until the context deadline; reading through qtx completes immediately.
|
||||
type beginHookDB struct {
|
||||
type privatePreflightHookDB struct {
|
||||
*pgxpool.Pool
|
||||
once sync.Once
|
||||
before func(context.Context) error
|
||||
beforeErr error
|
||||
committed bool
|
||||
}
|
||||
|
||||
func (db *beginHookDB) Begin(ctx context.Context) (pgx.Tx, error) {
|
||||
db.once.Do(func() {
|
||||
if db.before != nil {
|
||||
db.beforeErr = db.before(ctx)
|
||||
}
|
||||
})
|
||||
if db.beforeErr != nil {
|
||||
return nil, db.beforeErr
|
||||
type privatePreflightRow struct {
|
||||
pgx.Row
|
||||
ctx context.Context
|
||||
db *privatePreflightHookDB
|
||||
}
|
||||
|
||||
func (db *privatePreflightHookDB) QueryRow(ctx context.Context, query string, args ...any) pgx.Row {
|
||||
row := db.Pool.QueryRow(ctx, query, args...)
|
||||
if strings.HasPrefix(query, "-- name: GetPrivateMessageByRandomID :one") {
|
||||
return privatePreflightRow{Row: row, ctx: ctx, db: db}
|
||||
}
|
||||
return db.Pool.Begin(ctx)
|
||||
return row
|
||||
}
|
||||
|
||||
func (row privatePreflightRow) Scan(dest ...any) error {
|
||||
err := row.Row.Scan(dest...)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
row.db.once.Do(func() {
|
||||
row.db.beforeErr = row.db.before(row.ctx)
|
||||
row.db.committed = row.db.beforeErr == nil
|
||||
})
|
||||
if row.db.beforeErr != nil {
|
||||
return row.db.beforeErr
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func TestMessageStorePrivateRandomIDConflictFallbackUsesTransactionConnection(t *testing.T) {
|
||||
|
|
@ -302,7 +322,7 @@ func TestMessageStorePrivateRandomIDConflictFallbackUsesTransactionConnection(t
|
|||
Message: "commit between preflight and insert", Date: 1700001200,
|
||||
}
|
||||
boxIDs := &perUserCounterAllocator{}
|
||||
db := &beginHookDB{Pool: pool}
|
||||
db := &privatePreflightHookDB{Pool: pool}
|
||||
db.before = func(ctx context.Context) error {
|
||||
remote := NewMessageStore(pool, WithMessageAllocators(boxIDs))
|
||||
// A separate actor represents another server process. Cross-process
|
||||
|
|
@ -322,4 +342,7 @@ func TestMessageStorePrivateRandomIDConflictFallbackUsesTransactionConnection(t
|
|||
if !got.Duplicate || got.SenderMessage.ID == 0 || got.RecipientMessage.ID == 0 {
|
||||
t.Fatalf("conflict fallback result = %+v, want committed duplicate boxes", got)
|
||||
}
|
||||
if !db.committed {
|
||||
t.Fatal("competing send did not commit after the preflight miss")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,8 +4,156 @@ import (
|
|||
"context"
|
||||
"sync"
|
||||
"telesrv/internal/domain"
|
||||
"telesrv/internal/store/postgres/sqlcgen"
|
||||
)
|
||||
|
||||
type testAllocatorBundle struct {
|
||||
boxIDs *testBoxIDAllocator
|
||||
channelIDs *testChannelIDAllocator
|
||||
channelMessageIDs *testChannelMessageIDAllocator
|
||||
}
|
||||
|
||||
var testAllocatorBundles sync.Map
|
||||
|
||||
func testAllocatorsFor(db sqlcgen.DBTX) *testAllocatorBundle {
|
||||
if existing, ok := testAllocatorBundles.Load(db); ok {
|
||||
return existing.(*testAllocatorBundle)
|
||||
}
|
||||
bundle := &testAllocatorBundle{
|
||||
boxIDs: &testBoxIDAllocator{source: NewMessageBoxCounterSource(db)},
|
||||
channelIDs: &testChannelIDAllocator{source: NewChannelIDCounterSource(db)},
|
||||
channelMessageIDs: &testChannelMessageIDAllocator{source: NewChannelMessageIDCounterSource(db)},
|
||||
}
|
||||
actual, _ := testAllocatorBundles.LoadOrStore(db, bundle)
|
||||
return actual.(*testAllocatorBundle)
|
||||
}
|
||||
|
||||
func newTestMessageStore(db sqlcgen.DBTX, opts ...MessageStoreOption) *MessageStore {
|
||||
bundle := testAllocatorsFor(db)
|
||||
all := append([]MessageStoreOption{WithMessageAllocators(bundle.boxIDs)}, opts...)
|
||||
return NewMessageStore(db, all...)
|
||||
}
|
||||
|
||||
func newTestChannelStore(db sqlcgen.DBTX, opts ...ChannelStoreOption) *ChannelStore {
|
||||
bundle := testAllocatorsFor(db)
|
||||
all := append([]ChannelStoreOption{WithChannelAllocators(bundle.channelIDs, bundle.channelMessageIDs)}, opts...)
|
||||
return NewChannelStore(db, all...)
|
||||
}
|
||||
|
||||
type testBoxIDAllocator struct {
|
||||
mu sync.Mutex
|
||||
source *MessageBoxCounterSource
|
||||
values map[int64]int
|
||||
}
|
||||
|
||||
func (a *testBoxIDAllocator) NextBoxID(ctx context.Context, userID int64) (int, error) {
|
||||
values, err := a.NextBoxIDs(ctx, []int64{userID})
|
||||
return values[userID], err
|
||||
}
|
||||
|
||||
func (a *testBoxIDAllocator) NextBoxIDs(ctx context.Context, userIDs []int64) (map[int64]int, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
current, err := a.source.CurrentBatch(ctx, userIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if a.values == nil {
|
||||
a.values = make(map[int64]int, len(userIDs))
|
||||
}
|
||||
out := make(map[int64]int, len(userIDs))
|
||||
for _, userID := range userIDs {
|
||||
if current[userID] > a.values[userID] {
|
||||
a.values[userID] = current[userID]
|
||||
}
|
||||
a.values[userID]++
|
||||
out[userID] = a.values[userID]
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *testBoxIDAllocator) CurrentBoxID(ctx context.Context, userID int64) (int, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
current, err := a.source.Current(ctx, userID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if a.values[userID] > current {
|
||||
return a.values[userID], nil
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
type testChannelIDAllocator struct {
|
||||
mu sync.Mutex
|
||||
source *ChannelIDCounterSource
|
||||
current int64
|
||||
}
|
||||
|
||||
func (a *testChannelIDAllocator) NextChannelID(ctx context.Context) (int64, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
current, err := a.source.Current(ctx, 0)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if int64(current) > a.current {
|
||||
a.current = int64(current)
|
||||
}
|
||||
a.current++
|
||||
return a.current, nil
|
||||
}
|
||||
|
||||
func (a *testChannelIDAllocator) CurrentChannelID(ctx context.Context) (int64, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
current, err := a.source.Current(ctx, 0)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if int64(current) > a.current {
|
||||
a.current = int64(current)
|
||||
}
|
||||
return a.current, nil
|
||||
}
|
||||
|
||||
type testChannelMessageIDAllocator struct {
|
||||
mu sync.Mutex
|
||||
source *ChannelMessageIDCounterSource
|
||||
values map[int64]int
|
||||
}
|
||||
|
||||
func (a *testChannelMessageIDAllocator) NextChannelMessageID(ctx context.Context, channelID int64) (int, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
current, err := a.source.Current(ctx, channelID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if a.values == nil {
|
||||
a.values = make(map[int64]int)
|
||||
}
|
||||
if current > a.values[channelID] {
|
||||
a.values[channelID] = current
|
||||
}
|
||||
a.values[channelID]++
|
||||
return a.values[channelID], nil
|
||||
}
|
||||
|
||||
func (a *testChannelMessageIDAllocator) CurrentChannelMessageID(ctx context.Context, channelID int64) (int, error) {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
current, err := a.source.Current(ctx, channelID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if a.values[channelID] > current {
|
||||
return a.values[channelID], nil
|
||||
}
|
||||
return current, nil
|
||||
}
|
||||
|
||||
type fixedBoxIDAllocator struct {
|
||||
next int
|
||||
}
|
||||
|
|
|
|||
152
internal/store/postgres/phone_identity_migration.go
Normal file
152
internal/store/postgres/phone_identity_migration.go
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
const phoneIdentityMigrationBatchSize = 1000
|
||||
|
||||
type phoneIdentityCandidate struct {
|
||||
userID int64
|
||||
canonicalPhone string
|
||||
needsUpdate bool
|
||||
}
|
||||
|
||||
// canonicalizeStoredPhoneIdentities is the data half of migration 0182. SQL
|
||||
// alone cannot correctly distinguish a removable national trunk prefix from a
|
||||
// significant leading zero, so the application numbering-plan metadata builds
|
||||
// a complete plan first. Legacy values that do not describe any valid phone
|
||||
// identity are left untouched and therefore remain unreachable through the
|
||||
// canonical login path. No users row is changed until no two parseable rows
|
||||
// converge on the same E.164 identity.
|
||||
func canonicalizeStoredPhoneIdentities(ctx context.Context, conn *pgx.Conn) error {
|
||||
tx, err := conn.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
|
||||
if err != nil {
|
||||
return fmt.Errorf("begin phone identity audit: %w", err)
|
||||
}
|
||||
committed := false
|
||||
defer func() {
|
||||
if !committed {
|
||||
_ = tx.Rollback(context.Background())
|
||||
}
|
||||
}()
|
||||
if err := canonicalizeStoredPhoneIdentitiesTx(ctx, tx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return fmt.Errorf("commit phone identity migration: %w", err)
|
||||
}
|
||||
committed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalizeStoredPhoneIdentitiesTx(ctx context.Context, tx pgx.Tx) error {
|
||||
return canonicalizeStoredPhoneIdentitiesInTableTx(ctx, tx, pgx.Identifier{"public", "users"})
|
||||
}
|
||||
|
||||
func canonicalizeStoredPhoneIdentitiesInTableTx(ctx context.Context, tx pgx.Tx, usersTable pgx.Identifier) error {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
CREATE TEMP TABLE phone_identity_0182 (
|
||||
user_id bigint PRIMARY KEY,
|
||||
canonical_phone text NOT NULL,
|
||||
needs_update boolean NOT NULL
|
||||
) ON COMMIT DROP`); err != nil {
|
||||
return fmt.Errorf("create phone identity plan: %w", err)
|
||||
}
|
||||
|
||||
lastID := int64(math.MinInt64)
|
||||
for {
|
||||
rows, err := tx.Query(ctx, fmt.Sprintf(`
|
||||
SELECT id, phone
|
||||
FROM %s
|
||||
WHERE id > $1 AND phone <> ''
|
||||
ORDER BY id
|
||||
LIMIT $2`, usersTable.Sanitize()), lastID, phoneIdentityMigrationBatchSize)
|
||||
if err != nil {
|
||||
return fmt.Errorf("scan stored phone identities: %w", err)
|
||||
}
|
||||
rowsRead := 0
|
||||
candidates := make([]phoneIdentityCandidate, 0, phoneIdentityMigrationBatchSize)
|
||||
for rows.Next() {
|
||||
rowsRead++
|
||||
var userID int64
|
||||
var rawPhone string
|
||||
if err := rows.Scan(&userID, &rawPhone); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("read stored phone identity: %w", err)
|
||||
}
|
||||
lastID = userID
|
||||
canonical := rawPhone
|
||||
if !domain.IsSystemUserID(userID) {
|
||||
canonical = domain.NormalizePhone(rawPhone)
|
||||
if canonical == "" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
candidates = append(candidates, phoneIdentityCandidate{
|
||||
userID: userID,
|
||||
canonicalPhone: canonical,
|
||||
needsUpdate: canonical != rawPhone,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
return fmt.Errorf("iterate stored phone identities: %w", err)
|
||||
}
|
||||
rows.Close()
|
||||
if len(candidates) > 0 {
|
||||
copyRows := make([][]any, 0, len(candidates))
|
||||
for _, candidate := range candidates {
|
||||
copyRows = append(copyRows, []any{candidate.userID, candidate.canonicalPhone, candidate.needsUpdate})
|
||||
}
|
||||
if _, err := tx.CopyFrom(ctx,
|
||||
pgx.Identifier{"phone_identity_0182"},
|
||||
[]string{"user_id", "canonical_phone", "needs_update"},
|
||||
pgx.CopyFromRows(copyRows),
|
||||
); err != nil {
|
||||
return fmt.Errorf("stage phone identity plan: %w", err)
|
||||
}
|
||||
}
|
||||
if rowsRead < phoneIdentityMigrationBatchSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
CREATE INDEX phone_identity_0182_canonical_idx
|
||||
ON phone_identity_0182(canonical_phone, user_id)`); err != nil {
|
||||
return fmt.Errorf("index phone identity plan: %w", err)
|
||||
}
|
||||
|
||||
var firstUserID, secondUserID int64
|
||||
err := tx.QueryRow(ctx, `
|
||||
SELECT first.user_id, second.user_id
|
||||
FROM phone_identity_0182 AS first
|
||||
JOIN phone_identity_0182 AS second
|
||||
ON second.canonical_phone = first.canonical_phone
|
||||
AND second.user_id > first.user_id
|
||||
ORDER BY first.user_id, second.user_id
|
||||
LIMIT 1`).Scan(&firstUserID, &secondUserID)
|
||||
switch {
|
||||
case err == nil:
|
||||
return fmt.Errorf("users %d and %d resolve to the same canonical phone", firstUserID, secondUserID)
|
||||
case err != pgx.ErrNoRows:
|
||||
return fmt.Errorf("audit canonical phone uniqueness: %w", err)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, fmt.Sprintf(`
|
||||
UPDATE %s AS target
|
||||
SET phone = planned.canonical_phone
|
||||
FROM phone_identity_0182 AS planned
|
||||
WHERE planned.user_id = target.id
|
||||
AND planned.needs_update`, usersTable.Sanitize())); err != nil {
|
||||
return fmt.Errorf("apply canonical phone identities: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
)
|
||||
|
||||
func TestPhoneIdentityMigrationCanonicalizesOnlyAfterCompleteAuditPostgres(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("canonicalizes national trunk and preserves significant zero", func(t *testing.T) {
|
||||
tx := beginPhoneIdentityFixtureTx(t, ctx, pool)
|
||||
defer func() { _ = tx.Rollback(context.Background()) }()
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO users(id, phone) VALUES
|
||||
(1001, '9809981679461'),
|
||||
(1002, '390212345678'),
|
||||
($1, $2)`, domain.OfficialSystemUserID, domain.OfficialSystemPhone); err != nil {
|
||||
t.Fatalf("insert phone fixtures: %v", err)
|
||||
}
|
||||
|
||||
if err := canonicalizeStoredPhoneIdentitiesInTableTx(ctx, tx, pgx.Identifier{"users"}); err != nil {
|
||||
t.Fatalf("canonicalizeStoredPhoneIdentitiesInTableTx: %v", err)
|
||||
}
|
||||
assertFixturePhone(t, ctx, tx, 1001, "989981679461")
|
||||
assertFixturePhone(t, ctx, tx, 1002, "390212345678")
|
||||
assertFixturePhone(t, ctx, tx, domain.OfficialSystemUserID, domain.OfficialSystemPhone)
|
||||
})
|
||||
|
||||
t.Run("duplicate canonical identity fails without changing rows", func(t *testing.T) {
|
||||
tx := beginPhoneIdentityFixtureTx(t, ctx, pool)
|
||||
defer func() { _ = tx.Rollback(context.Background()) }()
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO users(id, phone) VALUES
|
||||
(2001, '9809981679461'),
|
||||
(2002, '989981679461')`); err != nil {
|
||||
t.Fatalf("insert duplicate fixtures: %v", err)
|
||||
}
|
||||
|
||||
err := canonicalizeStoredPhoneIdentitiesInTableTx(ctx, tx, pgx.Identifier{"users"})
|
||||
if err == nil || !strings.Contains(err.Error(), "users 2001 and 2002 resolve to the same canonical phone") {
|
||||
t.Fatalf("canonicalize err = %v, want duplicate user IDs", err)
|
||||
}
|
||||
assertFixturePhone(t, ctx, tx, 2001, "9809981679461")
|
||||
assertFixturePhone(t, ctx, tx, 2002, "989981679461")
|
||||
})
|
||||
|
||||
t.Run("invalid ordinary identity remains unreachable without blocking valid rows", func(t *testing.T) {
|
||||
tx := beginPhoneIdentityFixtureTx(t, ctx, pool)
|
||||
defer func() { _ = tx.Rollback(context.Background()) }()
|
||||
const invalidPhone = "legacy-not-a-phone"
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO users(id, phone) VALUES
|
||||
(3001, $1),
|
||||
(3002, '9809981679461')`, invalidPhone); err != nil {
|
||||
t.Fatalf("insert invalid fixture: %v", err)
|
||||
}
|
||||
|
||||
if err := canonicalizeStoredPhoneIdentitiesInTableTx(ctx, tx, pgx.Identifier{"users"}); err != nil {
|
||||
t.Fatalf("canonicalizeStoredPhoneIdentitiesInTableTx: %v", err)
|
||||
}
|
||||
assertFixturePhone(t, ctx, tx, 3001, invalidPhone)
|
||||
assertFixturePhone(t, ctx, tx, 3002, "989981679461")
|
||||
})
|
||||
}
|
||||
|
||||
func beginPhoneIdentityFixtureTx(t *testing.T, ctx context.Context, pool interface {
|
||||
Begin(context.Context) (pgx.Tx, error)
|
||||
}) pgx.Tx {
|
||||
t.Helper()
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("begin phone identity fixture: %v", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `CREATE TEMP TABLE users (id bigint PRIMARY KEY, phone text NOT NULL) ON COMMIT DROP`); err != nil {
|
||||
_ = tx.Rollback(context.Background())
|
||||
t.Fatalf("create isolated users fixture: %v", err)
|
||||
}
|
||||
return tx
|
||||
}
|
||||
|
||||
func assertFixturePhone(t *testing.T, ctx context.Context, tx pgx.Tx, userID int64, want string) {
|
||||
t.Helper()
|
||||
var got string
|
||||
if err := tx.QueryRow(ctx, `SELECT phone FROM users WHERE id=$1`, userID).Scan(&got); err != nil {
|
||||
t.Fatalf("read user %d phone: %v", userID, err)
|
||||
}
|
||||
if got != want {
|
||||
t.Fatalf("user %d phone = %q, want %q", userID, got, want)
|
||||
}
|
||||
}
|
||||
|
|
@ -21,6 +21,14 @@ import (
|
|||
|
||||
const defaultMinConns = 16
|
||||
|
||||
const (
|
||||
phoneIdentityPredecessorVersion = uint(181)
|
||||
phoneIdentityMigrationVersion = uint(182)
|
||||
// "phone182" as a signed PostgreSQL advisory-lock key. The lock spans the
|
||||
// two-stage 0181 -> identity audit -> 0182 transition across new binaries.
|
||||
phoneIdentityMigrationLockKey = int64(0x70686f6e65313832)
|
||||
)
|
||||
|
||||
// MigrationStatus 是启动迁移后的 schema 状态。
|
||||
type MigrationStatus struct {
|
||||
Version uint
|
||||
|
|
@ -73,6 +81,9 @@ func Open(ctx context.Context, dsn string, opts ...PoolOption) (*pgxpool.Pool, e
|
|||
}
|
||||
}
|
||||
cfg.ConnConfig.Tracer = queryStatsTracer{}
|
||||
if err := installPostgresConnectionAdmission(ctx, cfg, dsn); err != nil {
|
||||
return nil, fmt.Errorf("initialize PostgreSQL connection admission: %w", err)
|
||||
}
|
||||
pool, err := pgxpool.NewWithConfig(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("pgxpool new: %w", err)
|
||||
|
|
@ -142,6 +153,19 @@ func Migrate(dsn string) error {
|
|||
|
||||
// MigrateAndStatus 用嵌入迁移脚本迁移数据库,并返回迁移后的 schema 版本。
|
||||
func MigrateAndStatus(dsn string) (MigrationStatus, error) {
|
||||
ctx := context.Background()
|
||||
lockConn, err := pgx.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
return MigrationStatus{}, fmt.Errorf("connect migration lock: %w", err)
|
||||
}
|
||||
defer lockConn.Close(ctx)
|
||||
if _, err := lockConn.Exec(ctx, `SELECT pg_advisory_lock($1)`, phoneIdentityMigrationLockKey); err != nil {
|
||||
return MigrationStatus{}, fmt.Errorf("lock migrations: %w", err)
|
||||
}
|
||||
defer func() {
|
||||
_, _ = lockConn.Exec(context.Background(), `SELECT pg_advisory_unlock($1)`, phoneIdentityMigrationLockKey)
|
||||
}()
|
||||
|
||||
src, err := iofs.New(deploy.Migrations, "migrations")
|
||||
if err != nil {
|
||||
return MigrationStatus{}, fmt.Errorf("iofs source: %w", err)
|
||||
|
|
@ -151,9 +175,37 @@ func MigrateAndStatus(dsn string) (MigrationStatus, error) {
|
|||
return MigrationStatus{}, fmt.Errorf("migrate new: %w", err)
|
||||
}
|
||||
defer m.Close()
|
||||
status, err := migrationStatus(m)
|
||||
if err != nil {
|
||||
return MigrationStatus{}, err
|
||||
}
|
||||
if status.Dirty {
|
||||
return MigrationStatus{}, fmt.Errorf("migrate version %d is dirty", status.Version)
|
||||
}
|
||||
if status.Empty || status.Version < phoneIdentityPredecessorVersion {
|
||||
if err := m.Migrate(phoneIdentityPredecessorVersion); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return MigrationStatus{}, fmt.Errorf("migrate to phone identity predecessor: %w", err)
|
||||
}
|
||||
status, err = migrationStatus(m)
|
||||
if err != nil {
|
||||
return MigrationStatus{}, err
|
||||
}
|
||||
if status.Dirty || status.Empty || status.Version != phoneIdentityPredecessorVersion {
|
||||
return MigrationStatus{}, fmt.Errorf("phone identity predecessor status = %+v", status)
|
||||
}
|
||||
}
|
||||
if status.Version < phoneIdentityMigrationVersion {
|
||||
if err := canonicalizeStoredPhoneIdentities(ctx, lockConn); err != nil {
|
||||
return MigrationStatus{}, fmt.Errorf("migrate phone identities: %w", err)
|
||||
}
|
||||
}
|
||||
if err := m.Up(); err != nil && !errors.Is(err, migrate.ErrNoChange) {
|
||||
return MigrationStatus{}, fmt.Errorf("migrate up: %w", err)
|
||||
}
|
||||
return migrationStatus(m)
|
||||
}
|
||||
|
||||
func migrationStatus(m *migrate.Migrate) (MigrationStatus, error) {
|
||||
version, dirty, err := m.Version()
|
||||
if errors.Is(err, migrate.ErrNilVersion) {
|
||||
return MigrationStatus{Empty: true}, nil
|
||||
|
|
|
|||
466
internal/store/postgres/postgres_connection_admission.go
Normal file
466
internal/store/postgres/postgres_connection_admission.go
Normal file
|
|
@ -0,0 +1,466 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const (
|
||||
// A constructor first owns the reservation key, then the new PostgreSQL
|
||||
// backend takes the owner key before the reservation is released. No
|
||||
// backend is therefore opened unless one application slot is already
|
||||
// fenced for it. The owner lock is session scoped and is released by
|
||||
// PostgreSQL on clean close, process crash, or network loss.
|
||||
postgresAdmissionLockClass int32 = 0x54454c45 // "TELE": live owner
|
||||
postgresAdmissionReservationLockClass int32 = 0x54454c52 // "TELR": pre-connect reservation
|
||||
minimumOperatorConnections = 8
|
||||
operatorConnectionReserveRatio = 0.05
|
||||
|
||||
postgresAdmissionConnectTimeout = 10 * time.Second
|
||||
postgresAdmissionReservationSlack = 5 * time.Second
|
||||
postgresAdmissionProbeBatch = 8
|
||||
postgresAdmissionProbeInterval = 25 * time.Millisecond
|
||||
postgresAdmissionApplicationNamePrefix = "telesrv-admission/"
|
||||
|
||||
// Admission slots are shared by every process that targets the same
|
||||
// PostgreSQL server. Pools may retain their configured minimum, but burst
|
||||
// connections must return idle slots promptly so another process can make
|
||||
// progress without a per-role connection budget.
|
||||
postgresAdmissionMaxConnIdleTime = 5 * time.Second
|
||||
postgresAdmissionHealthCheckPeriod = time.Second
|
||||
)
|
||||
|
||||
type postgresConnectionCapacity struct {
|
||||
maxConnections int
|
||||
superuserReserved int
|
||||
serverReserved int
|
||||
operatorReserved int
|
||||
applicationSlots int
|
||||
}
|
||||
|
||||
func calculatePostgresConnectionCapacity(maxConnections, superuserReserved, serverReserved int) (postgresConnectionCapacity, error) {
|
||||
if maxConnections <= 0 || superuserReserved < 0 || serverReserved < 0 {
|
||||
return postgresConnectionCapacity{}, errors.New("invalid PostgreSQL connection settings")
|
||||
}
|
||||
operatorReserved := max(minimumOperatorConnections, int(math.Ceil(float64(maxConnections)*operatorConnectionReserveRatio)))
|
||||
applicationSlots := maxConnections - superuserReserved - serverReserved - operatorReserved
|
||||
if applicationSlots <= 0 {
|
||||
return postgresConnectionCapacity{}, fmt.Errorf(
|
||||
"PostgreSQL has no application connection capacity: max=%d superuser_reserved=%d reserved=%d operator_reserved=%d",
|
||||
maxConnections, superuserReserved, serverReserved, operatorReserved,
|
||||
)
|
||||
}
|
||||
return postgresConnectionCapacity{
|
||||
maxConnections: maxConnections, superuserReserved: superuserReserved,
|
||||
serverReserved: serverReserved, operatorReserved: operatorReserved,
|
||||
applicationSlots: applicationSlots,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type postgresAdmissionReservation struct {
|
||||
token string
|
||||
slot int32
|
||||
generation uint64
|
||||
timer *time.Timer
|
||||
}
|
||||
|
||||
type postgresAdmissionController struct {
|
||||
dsn string
|
||||
|
||||
mu sync.Mutex
|
||||
conn *pgx.Conn
|
||||
generation uint64
|
||||
capacity postgresConnectionCapacity
|
||||
ownerSlot int32
|
||||
nextSlot int
|
||||
pending map[string]*postgresAdmissionReservation
|
||||
reserved map[int32]string
|
||||
sequence atomic.Uint64
|
||||
}
|
||||
|
||||
type postgresAdmissionControllerInit struct {
|
||||
ready chan struct{}
|
||||
controller *postgresAdmissionController
|
||||
err error
|
||||
}
|
||||
|
||||
var postgresAdmissionControllers sync.Map
|
||||
|
||||
func postgresAdmissionControllerForDSN(ctx context.Context, dsn string) (*postgresAdmissionController, error) {
|
||||
key := strings.TrimSpace(dsn)
|
||||
if key == "" {
|
||||
return nil, errors.New("PostgreSQL DSN is required for connection admission")
|
||||
}
|
||||
created := &postgresAdmissionControllerInit{ready: make(chan struct{})}
|
||||
actual, loaded := postgresAdmissionControllers.LoadOrStore(key, created)
|
||||
entry := actual.(*postgresAdmissionControllerInit)
|
||||
if !loaded {
|
||||
entry.controller, entry.err = newPostgresAdmissionController(ctx, key)
|
||||
close(entry.ready)
|
||||
if entry.err != nil {
|
||||
postgresAdmissionControllers.CompareAndDelete(key, entry)
|
||||
}
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, ctx.Err()
|
||||
case <-entry.ready:
|
||||
}
|
||||
if entry.err != nil {
|
||||
return nil, entry.err
|
||||
}
|
||||
return entry.controller, nil
|
||||
}
|
||||
|
||||
func newPostgresAdmissionController(ctx context.Context, dsn string) (*postgresAdmissionController, error) {
|
||||
controller := &postgresAdmissionController{
|
||||
dsn: dsn, pending: make(map[string]*postgresAdmissionReservation), reserved: make(map[int32]string),
|
||||
}
|
||||
controller.mu.Lock()
|
||||
err := controller.connectLocked(ctx)
|
||||
controller.mu.Unlock()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return controller, nil
|
||||
}
|
||||
|
||||
func (c *postgresAdmissionController) connectLocked(ctx context.Context) error {
|
||||
if c.conn != nil && !c.conn.IsClosed() {
|
||||
return nil
|
||||
}
|
||||
config, err := pgx.ParseConfig(c.dsn)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse PostgreSQL admission controller config: %w", err)
|
||||
}
|
||||
if config.ConnectTimeout <= 0 {
|
||||
config.ConnectTimeout = postgresAdmissionConnectTimeout
|
||||
}
|
||||
config.RuntimeParams["application_name"] = "telesrv-admission-controller"
|
||||
conn, err := pgx.ConnectConfig(ctx, config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect PostgreSQL admission controller: %w", err)
|
||||
}
|
||||
var maxConnections, superuserReserved, serverReserved int
|
||||
if err := conn.QueryRow(ctx, `
|
||||
SELECT current_setting('max_connections')::integer,
|
||||
current_setting('superuser_reserved_connections')::integer,
|
||||
COALESCE(NULLIF(current_setting('reserved_connections', true), ''), '0')::integer`).Scan(
|
||||
&maxConnections, &superuserReserved, &serverReserved,
|
||||
); err != nil {
|
||||
_ = conn.Close(context.Background())
|
||||
return fmt.Errorf("read PostgreSQL connection capacity: %w", err)
|
||||
}
|
||||
capacity, err := calculatePostgresConnectionCapacity(maxConnections, superuserReserved, serverReserved)
|
||||
if err != nil {
|
||||
_ = conn.Close(context.Background())
|
||||
return err
|
||||
}
|
||||
c.conn = conn
|
||||
c.capacity = capacity
|
||||
if err := c.claimControllerSlotLocked(ctx); err != nil {
|
||||
_ = conn.Close(context.Background())
|
||||
c.conn = nil
|
||||
return err
|
||||
}
|
||||
c.generation++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *postgresAdmissionController) claimControllerSlotLocked(ctx context.Context) error {
|
||||
for {
|
||||
for offset := 0; offset < c.capacity.applicationSlots; offset++ {
|
||||
slot := int32((c.nextSlot+offset)%c.capacity.applicationSlots + 1)
|
||||
var reserved bool
|
||||
if err := c.conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1::integer, $2::integer)`, postgresAdmissionReservationLockClass, slot).Scan(&reserved); err != nil {
|
||||
return fmt.Errorf("reserve PostgreSQL admission controller slot %d: %w", slot, err)
|
||||
}
|
||||
if !reserved {
|
||||
continue
|
||||
}
|
||||
var acquired bool
|
||||
if err := c.conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1::integer, $2::integer)`, postgresAdmissionLockClass, slot).Scan(&acquired); err != nil {
|
||||
return fmt.Errorf("claim PostgreSQL admission controller slot %d: %w", slot, err)
|
||||
}
|
||||
if !acquired {
|
||||
var released bool
|
||||
if err := c.conn.QueryRow(ctx, `SELECT pg_advisory_unlock($1::integer, $2::integer)`, postgresAdmissionReservationLockClass, slot).Scan(&released); err != nil {
|
||||
return fmt.Errorf("release occupied PostgreSQL admission controller slot %d: %w", slot, err)
|
||||
}
|
||||
if !released {
|
||||
return fmt.Errorf("PostgreSQL admission controller slot %d reservation was not held", slot)
|
||||
}
|
||||
continue
|
||||
}
|
||||
var released bool
|
||||
if err := c.conn.QueryRow(ctx, `SELECT pg_advisory_unlock($1::integer, $2::integer)`, postgresAdmissionReservationLockClass, slot).Scan(&released); err != nil {
|
||||
return fmt.Errorf("release PostgreSQL admission controller slot %d reservation: %w", slot, err)
|
||||
}
|
||||
if !released {
|
||||
return fmt.Errorf("PostgreSQL admission controller slot %d reservation was not held", slot)
|
||||
}
|
||||
c.ownerSlot = slot
|
||||
c.nextSlot = int(slot) % c.capacity.applicationSlots
|
||||
return nil
|
||||
}
|
||||
if !sleepPostgresAdmission(ctx, postgresAdmissionProbeInterval) {
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *postgresAdmissionController) resetLocked() {
|
||||
if c.conn != nil {
|
||||
_ = c.conn.Close(context.Background())
|
||||
c.conn = nil
|
||||
}
|
||||
c.ownerSlot = 0
|
||||
c.generation++
|
||||
clear(c.reserved)
|
||||
}
|
||||
|
||||
func (c *postgresAdmissionController) reserve(ctx context.Context, ttl time.Duration) (string, error) {
|
||||
if ttl <= 0 {
|
||||
ttl = postgresAdmissionConnectTimeout + postgresAdmissionReservationSlack
|
||||
}
|
||||
for {
|
||||
c.mu.Lock()
|
||||
if err := c.connectLocked(ctx); err != nil {
|
||||
c.mu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
probes := min(postgresAdmissionProbeBatch, c.capacity.applicationSlots)
|
||||
for probe := 0; probe < probes; probe++ {
|
||||
slot := int32(c.nextSlot%c.capacity.applicationSlots + 1)
|
||||
c.nextSlot = (c.nextSlot + 1) % c.capacity.applicationSlots
|
||||
if slot == c.ownerSlot || c.reserved[slot] != "" {
|
||||
continue
|
||||
}
|
||||
free, err := c.reserveSlotLocked(ctx, slot)
|
||||
if err != nil {
|
||||
c.resetLocked()
|
||||
c.mu.Unlock()
|
||||
return "", err
|
||||
}
|
||||
if !free {
|
||||
continue
|
||||
}
|
||||
token := fmt.Sprintf("%016x", c.sequence.Add(1))
|
||||
reservation := &postgresAdmissionReservation{token: token, slot: slot, generation: c.generation}
|
||||
c.pending[token] = reservation
|
||||
c.reserved[slot] = token
|
||||
reservation.timer = time.AfterFunc(ttl, func() { c.abort(token) })
|
||||
c.mu.Unlock()
|
||||
return token, nil
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if !sleepPostgresAdmission(ctx, postgresAdmissionProbeInterval) {
|
||||
return "", ctx.Err()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *postgresAdmissionController) reserveSlotLocked(ctx context.Context, slot int32) (bool, error) {
|
||||
var reserved bool
|
||||
if err := c.conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1::integer, $2::integer)`, postgresAdmissionReservationLockClass, slot).Scan(&reserved); err != nil {
|
||||
return false, fmt.Errorf("reserve PostgreSQL connection slot %d: %w", slot, err)
|
||||
}
|
||||
if !reserved {
|
||||
return false, nil
|
||||
}
|
||||
var ownerFree bool
|
||||
if err := c.conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1::integer, $2::integer)`, postgresAdmissionLockClass, slot).Scan(&ownerFree); err != nil {
|
||||
return false, fmt.Errorf("probe PostgreSQL connection slot %d owner: %w", slot, err)
|
||||
}
|
||||
if ownerFree {
|
||||
var unlocked bool
|
||||
if err := c.conn.QueryRow(ctx, `SELECT pg_advisory_unlock($1::integer, $2::integer)`, postgresAdmissionLockClass, slot).Scan(&unlocked); err != nil {
|
||||
return false, fmt.Errorf("release PostgreSQL connection slot %d owner probe: %w", slot, err)
|
||||
}
|
||||
if !unlocked {
|
||||
return false, fmt.Errorf("PostgreSQL connection slot %d owner probe was not held", slot)
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
var released bool
|
||||
if err := c.conn.QueryRow(ctx, `SELECT pg_advisory_unlock($1::integer, $2::integer)`, postgresAdmissionReservationLockClass, slot).Scan(&released); err != nil {
|
||||
return false, fmt.Errorf("release occupied PostgreSQL connection slot %d reservation: %w", slot, err)
|
||||
}
|
||||
if !released {
|
||||
return false, fmt.Errorf("PostgreSQL connection slot %d reservation was not held", slot)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func (c *postgresAdmissionController) claim(ctx context.Context, token string, conn *pgx.Conn) error {
|
||||
reservation, err := c.take(token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var acquired bool
|
||||
if err := conn.QueryRow(ctx, `SELECT pg_try_advisory_lock($1::integer, $2::integer)`, postgresAdmissionLockClass, reservation.slot).Scan(&acquired); err != nil {
|
||||
_ = c.release(reservation)
|
||||
return fmt.Errorf("claim PostgreSQL connection slot %d: %w", reservation.slot, err)
|
||||
}
|
||||
if !acquired {
|
||||
_ = c.release(reservation)
|
||||
return fmt.Errorf("claim PostgreSQL connection slot %d: reservation ownership was lost", reservation.slot)
|
||||
}
|
||||
if err := c.release(reservation); err != nil {
|
||||
var ignored bool
|
||||
_ = conn.QueryRow(context.Background(), `SELECT pg_advisory_unlock($1::integer, $2::integer)`, postgresAdmissionLockClass, reservation.slot).Scan(&ignored)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *postgresAdmissionController) take(token string) (*postgresAdmissionReservation, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
reservation := c.pending[token]
|
||||
if reservation == nil {
|
||||
return nil, fmt.Errorf("PostgreSQL connection reservation %q is absent or expired", token)
|
||||
}
|
||||
delete(c.pending, token)
|
||||
if reservation.timer != nil {
|
||||
reservation.timer.Stop()
|
||||
}
|
||||
return reservation, nil
|
||||
}
|
||||
|
||||
func (c *postgresAdmissionController) abort(token string) {
|
||||
c.mu.Lock()
|
||||
reservation := c.pending[token]
|
||||
if reservation != nil {
|
||||
delete(c.pending, token)
|
||||
}
|
||||
c.mu.Unlock()
|
||||
if reservation != nil {
|
||||
_ = c.release(reservation)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *postgresAdmissionController) release(reservation *postgresAdmissionReservation) error {
|
||||
if reservation == nil {
|
||||
return nil
|
||||
}
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.reserved[reservation.slot] == reservation.token {
|
||||
delete(c.reserved, reservation.slot)
|
||||
}
|
||||
if reservation.generation != c.generation || c.conn == nil || c.conn.IsClosed() {
|
||||
return nil
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
var released bool
|
||||
if err := c.conn.QueryRow(ctx, `SELECT pg_advisory_unlock($1::integer, $2::integer)`, postgresAdmissionReservationLockClass, reservation.slot).Scan(&released); err != nil {
|
||||
c.resetLocked()
|
||||
return fmt.Errorf("release PostgreSQL connection slot %d reservation: %w", reservation.slot, err)
|
||||
}
|
||||
if !released {
|
||||
return fmt.Errorf("PostgreSQL connection slot %d reservation was not held", reservation.slot)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installPostgresConnectionAdmission(ctx context.Context, cfg *pgxpool.Config, dsn string) error {
|
||||
controller, err := postgresAdmissionControllerForDSN(ctx, dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
boundPostgresAdmissionPoolIdle(cfg)
|
||||
if cfg.ConnConfig.ConnectTimeout <= 0 {
|
||||
cfg.ConnConfig.ConnectTimeout = postgresAdmissionConnectTimeout
|
||||
}
|
||||
reservationTTL := cfg.ConnConfig.ConnectTimeout + postgresAdmissionReservationSlack
|
||||
previousBefore := cfg.BeforeConnect
|
||||
cfg.BeforeConnect = func(ctx context.Context, connConfig *pgx.ConnConfig) error {
|
||||
if previousBefore != nil {
|
||||
if err := previousBefore(ctx, connConfig); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
token, err := controller.reserve(ctx, reservationTTL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
connConfig.RuntimeParams["application_name"] = postgresAdmissionApplicationNamePrefix + token
|
||||
return nil
|
||||
}
|
||||
previousAfter := cfg.AfterConnect
|
||||
cfg.AfterConnect = func(ctx context.Context, conn *pgx.Conn) error {
|
||||
applicationName := conn.Config().RuntimeParams["application_name"]
|
||||
token, ok := strings.CutPrefix(applicationName, postgresAdmissionApplicationNamePrefix)
|
||||
if !ok || token == "" {
|
||||
return fmt.Errorf("PostgreSQL connection has no admission reservation identity")
|
||||
}
|
||||
if err := controller.claim(ctx, token, conn); err != nil {
|
||||
return err
|
||||
}
|
||||
if previousAfter != nil {
|
||||
return previousAfter(ctx, conn)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func boundPostgresAdmissionPoolIdle(cfg *pgxpool.Config) {
|
||||
if cfg.MaxConnIdleTime <= 0 || cfg.MaxConnIdleTime > postgresAdmissionMaxConnIdleTime {
|
||||
cfg.MaxConnIdleTime = postgresAdmissionMaxConnIdleTime
|
||||
}
|
||||
if cfg.HealthCheckPeriod <= 0 || cfg.HealthCheckPeriod > postgresAdmissionHealthCheckPeriod {
|
||||
cfg.HealthCheckPeriod = postgresAdmissionHealthCheckPeriod
|
||||
}
|
||||
}
|
||||
|
||||
func connectPostgresAdmitted(ctx context.Context, dsn string) (*pgx.Conn, error) {
|
||||
config, err := pgx.ParseConfig(dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("parse admitted PostgreSQL connection config: %w", err)
|
||||
}
|
||||
controller, err := postgresAdmissionControllerForDSN(ctx, dsn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if config.ConnectTimeout <= 0 {
|
||||
config.ConnectTimeout = postgresAdmissionConnectTimeout
|
||||
}
|
||||
token, err := controller.reserve(ctx, config.ConnectTimeout+postgresAdmissionReservationSlack)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
config.RuntimeParams["application_name"] = postgresAdmissionApplicationNamePrefix + token
|
||||
conn, err := pgx.ConnectConfig(ctx, config)
|
||||
if err != nil {
|
||||
controller.abort(token)
|
||||
return nil, fmt.Errorf("connect admitted PostgreSQL session: %w", err)
|
||||
}
|
||||
if err := controller.claim(ctx, token, conn); err != nil {
|
||||
_ = conn.Close(context.Background())
|
||||
return nil, err
|
||||
}
|
||||
return conn, nil
|
||||
}
|
||||
|
||||
func sleepPostgresAdmission(ctx context.Context, delay time.Duration) bool {
|
||||
timer := time.NewTimer(delay)
|
||||
defer timer.Stop()
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return false
|
||||
case <-timer.C:
|
||||
return true
|
||||
}
|
||||
}
|
||||
258
internal/store/postgres/postgres_connection_admission_test.go
Normal file
258
internal/store/postgres/postgres_connection_admission_test.go
Normal file
|
|
@ -0,0 +1,258 @@
|
|||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func TestCalculatePostgresConnectionCapacity(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
max, superuser, reserved, slots int
|
||||
wantErr bool
|
||||
}{
|
||||
{name: "postgres17-default-shape", max: 100, superuser: 3, reserved: 0, slots: 89},
|
||||
{name: "server-reserved", max: 100, superuser: 3, reserved: 2, slots: 87},
|
||||
{name: "minimum-operator-reserve", max: 20, superuser: 3, reserved: 0, slots: 9},
|
||||
{name: "percentage-operator-reserve", max: 1000, superuser: 3, reserved: 0, slots: 947},
|
||||
{name: "no-application-capacity", max: 5, superuser: 3, reserved: 0, wantErr: true},
|
||||
{name: "invalid-settings", max: 100, superuser: -1, reserved: 0, wantErr: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
got, err := calculatePostgresConnectionCapacity(test.max, test.superuser, test.reserved)
|
||||
if test.wantErr {
|
||||
if err == nil {
|
||||
t.Fatalf("capacity = %+v, want error", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("calculate capacity: %v", err)
|
||||
}
|
||||
if got.applicationSlots != test.slots || got.maxConnections != test.max ||
|
||||
got.superuserReserved != test.superuser || got.serverReserved != test.reserved {
|
||||
t.Fatalf("capacity = %+v, want slots=%d", got, test.slots)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBoundPostgresAdmissionPoolIdle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
idle time.Duration
|
||||
health time.Duration
|
||||
wantIdle time.Duration
|
||||
wantHealth time.Duration
|
||||
}{
|
||||
{
|
||||
name: "bound defaults",
|
||||
idle: 30 * time.Minute, health: time.Minute,
|
||||
wantIdle: postgresAdmissionMaxConnIdleTime, wantHealth: postgresAdmissionHealthCheckPeriod,
|
||||
},
|
||||
{
|
||||
name: "replace invalid zero values",
|
||||
wantIdle: postgresAdmissionMaxConnIdleTime, wantHealth: postgresAdmissionHealthCheckPeriod,
|
||||
},
|
||||
{
|
||||
name: "preserve tighter settings",
|
||||
idle: time.Second, health: 500 * time.Millisecond,
|
||||
wantIdle: time.Second, wantHealth: 500 * time.Millisecond,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cfg := &pgxpool.Config{MaxConnIdleTime: test.idle, HealthCheckPeriod: test.health}
|
||||
boundPostgresAdmissionPoolIdle(cfg)
|
||||
if cfg.MaxConnIdleTime != test.wantIdle || cfg.HealthCheckPeriod != test.wantHealth {
|
||||
t.Fatalf("idle/health = %s/%s, want %s/%s", cfg.MaxConnIdleTime, cfg.HealthCheckPeriod, test.wantIdle, test.wantHealth)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresAdmissionReservesCapacityBeforeOpeningBackend(t *testing.T) {
|
||||
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
controller, err := postgresAdmissionControllerForDSN(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open admission controller: %v", err)
|
||||
}
|
||||
observer, err := pgx.Connect(ctx, dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("open observer: %v", err)
|
||||
}
|
||||
defer observer.Close(ctx)
|
||||
|
||||
controller.mu.Lock()
|
||||
slots := controller.capacity.applicationSlots
|
||||
controller.mu.Unlock()
|
||||
available := slots - 1 // The controller's own backend is an admitted owner.
|
||||
var beforeBackends int
|
||||
if err := observer.QueryRow(ctx, `SELECT count(*) FROM pg_stat_activity WHERE datname = current_database()`).Scan(&beforeBackends); err != nil {
|
||||
t.Fatalf("count initial backends: %v", err)
|
||||
}
|
||||
tokens := make([]string, 0, available)
|
||||
defer func() {
|
||||
for _, token := range tokens {
|
||||
controller.abort(token)
|
||||
}
|
||||
}()
|
||||
for len(tokens) < available {
|
||||
token, err := controller.reserve(ctx, time.Minute)
|
||||
if err != nil {
|
||||
t.Fatalf("reserve slot %d/%d: %v", len(tokens)+1, available, err)
|
||||
}
|
||||
tokens = append(tokens, token)
|
||||
}
|
||||
|
||||
waitCtx, cancel := context.WithTimeout(ctx, 100*time.Millisecond)
|
||||
defer cancel()
|
||||
if token, err := controller.reserve(waitCtx, time.Minute); err == nil {
|
||||
controller.abort(token)
|
||||
t.Fatal("capacity-exhausted reservation unexpectedly succeeded")
|
||||
}
|
||||
var afterBackends, reservations, owners int
|
||||
if err := observer.QueryRow(ctx, `
|
||||
SELECT
|
||||
(SELECT count(*) FROM pg_stat_activity WHERE datname = current_database()),
|
||||
(SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND classid = $1::oid AND granted),
|
||||
(SELECT count(*) FROM pg_locks WHERE locktype = 'advisory' AND classid = $2::oid AND granted)`,
|
||||
uint32(postgresAdmissionReservationLockClass), uint32(postgresAdmissionLockClass),
|
||||
).Scan(&afterBackends, &reservations, &owners); err != nil {
|
||||
t.Fatalf("inspect reserved capacity: %v", err)
|
||||
}
|
||||
if afterBackends != beforeBackends {
|
||||
t.Fatalf("capacity wait opened PostgreSQL backends: before=%d after=%d", beforeBackends, afterBackends)
|
||||
}
|
||||
if reservations != available || owners != 1 {
|
||||
t.Fatalf("reservation/owner locks = %d/%d, want %d/1", reservations, owners, available)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresPoolConnectionsOwnDistinctAdmissionSlots(t *testing.T) {
|
||||
pool := testPool(t)
|
||||
ctx := context.Background()
|
||||
first, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire first pool connection: %v", err)
|
||||
}
|
||||
defer first.Release()
|
||||
second, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire second pool connection: %v", err)
|
||||
}
|
||||
defer second.Release()
|
||||
|
||||
admissionSlot := func(label string, conn *pgxpool.Conn) int64 {
|
||||
t.Helper()
|
||||
var count int
|
||||
var slot int64
|
||||
if err := conn.QueryRow(ctx, `
|
||||
SELECT count(*), min(objid::bigint)
|
||||
FROM pg_locks
|
||||
WHERE pid = pg_backend_pid()
|
||||
AND locktype = 'advisory'
|
||||
AND classid = $1::oid
|
||||
AND granted`, uint32(postgresAdmissionLockClass)).Scan(&count, &slot); err != nil {
|
||||
t.Fatalf("load %s admission slot: %v", label, err)
|
||||
}
|
||||
if count != 1 {
|
||||
t.Fatalf("%s connection owns %d admission slots, want 1", label, count)
|
||||
}
|
||||
return slot
|
||||
}
|
||||
firstSlot := admissionSlot("first", first)
|
||||
secondSlot := admissionSlot("second", second)
|
||||
if firstSlot <= 0 || secondSlot <= 0 || firstSlot == secondSlot {
|
||||
t.Fatalf("admission slots = %d/%d, want distinct positive slots", firstSlot, secondSlot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresAdmissionSlotReleasesWithPoolConnection(t *testing.T) {
|
||||
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
|
||||
}
|
||||
observer := testPool(t)
|
||||
ctx := context.Background()
|
||||
pool, err := Open(ctx, dsn, WithMaxConns(1), WithMinConns(1))
|
||||
if err != nil {
|
||||
t.Fatalf("open single-connection pool: %v", err)
|
||||
}
|
||||
conn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
pool.Close()
|
||||
t.Fatalf("acquire single-connection pool: %v", err)
|
||||
}
|
||||
var backendPID int
|
||||
if err := conn.QueryRow(ctx, `SELECT pg_backend_pid()`).Scan(&backendPID); err != nil {
|
||||
conn.Release()
|
||||
pool.Close()
|
||||
t.Fatalf("load admitted backend pid: %v", err)
|
||||
}
|
||||
conn.Release()
|
||||
pool.Close()
|
||||
|
||||
var retained int
|
||||
if err := observer.QueryRow(ctx, `
|
||||
SELECT count(*)
|
||||
FROM pg_locks
|
||||
WHERE pid = $1 AND locktype = 'advisory' AND classid = $2::oid`, backendPID, uint32(postgresAdmissionLockClass)).Scan(&retained); err != nil {
|
||||
t.Fatalf("inspect released admission slot: %v", err)
|
||||
}
|
||||
if retained != 0 {
|
||||
t.Fatalf("closed backend retained %d admission locks", retained)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPostgresPoolReturnsBurstAdmissionSlotsAfterIdle(t *testing.T) {
|
||||
dsn := os.Getenv("TELESRV_TEST_POSTGRES_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("set TELESRV_TEST_POSTGRES_DSN to run postgres integration test")
|
||||
}
|
||||
ctx := context.Background()
|
||||
pool, err := Open(ctx, dsn, WithMaxConns(3), WithMinConns(1))
|
||||
if err != nil {
|
||||
t.Fatalf("open elastic pool: %v", err)
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
connections := make([]*pgxpool.Conn, 0, 3)
|
||||
for len(connections) < cap(connections) {
|
||||
conn, err := pool.Acquire(ctx)
|
||||
if err != nil {
|
||||
t.Fatalf("acquire burst connection %d: %v", len(connections)+1, err)
|
||||
}
|
||||
connections = append(connections, conn)
|
||||
}
|
||||
for _, conn := range connections {
|
||||
conn.Release()
|
||||
}
|
||||
if got := pool.Stat().TotalConns(); got != 3 {
|
||||
t.Fatalf("burst pool total connections = %d, want 3", got)
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(postgresAdmissionMaxConnIdleTime + 3*postgresAdmissionHealthCheckPeriod)
|
||||
for time.Now().Before(deadline) {
|
||||
stat := pool.Stat()
|
||||
if stat.TotalConns() == 1 && stat.IdleConns() == 1 && stat.MaxIdleDestroyCount() >= 2 {
|
||||
return
|
||||
}
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
}
|
||||
stat := pool.Stat()
|
||||
t.Fatalf(
|
||||
"burst connections were not returned: total=%d idle=%d idle_destroyed=%d",
|
||||
stat.TotalConns(), stat.IdleConns(), stat.MaxIdleDestroyCount(),
|
||||
)
|
||||
}
|
||||
|
|
@ -58,6 +58,7 @@ WITH base AS (
|
|||
COALESCE(m.quote_text, '')::text AS message_quote_text,
|
||||
COALESCE(m.quote_entities::text, '[]')::text AS message_quote_entities_json,
|
||||
COALESCE(m.quote_offset, 0)::int AS message_quote_offset,
|
||||
COALESCE(m.reply_external, '{}'::jsonb)::text AS message_reply_external_json,
|
||||
COALESCE(m.fwd_from_peer_type, '')::text AS message_fwd_from_peer_type,
|
||||
COALESCE(m.fwd_from_peer_id, 0)::bigint AS message_fwd_from_peer_id,
|
||||
COALESCE(m.fwd_from_name, '')::text AS message_fwd_from_name,
|
||||
|
|
@ -224,6 +225,7 @@ SELECT
|
|||
message_quote_text,
|
||||
message_quote_entities_json,
|
||||
message_quote_offset,
|
||||
message_reply_external_json,
|
||||
message_fwd_from_peer_type,
|
||||
message_fwd_from_peer_id,
|
||||
message_fwd_from_name,
|
||||
|
|
@ -401,6 +403,7 @@ base AS (
|
|||
COALESCE(m.quote_text, '')::text AS message_quote_text,
|
||||
COALESCE(m.quote_entities::text, '[]')::text AS message_quote_entities_json,
|
||||
COALESCE(m.quote_offset, 0)::int AS message_quote_offset,
|
||||
COALESCE(m.reply_external, '{}'::jsonb)::text AS message_reply_external_json,
|
||||
COALESCE(m.fwd_from_peer_type, '')::text AS message_fwd_from_peer_type,
|
||||
COALESCE(m.fwd_from_peer_id, 0)::bigint AS message_fwd_from_peer_id,
|
||||
COALESCE(m.fwd_from_name, '')::text AS message_fwd_from_name,
|
||||
|
|
@ -486,6 +489,7 @@ SELECT
|
|||
message_quote_text,
|
||||
message_quote_entities_json,
|
||||
message_quote_offset,
|
||||
message_reply_external_json,
|
||||
message_fwd_from_peer_type,
|
||||
message_fwd_from_peer_id,
|
||||
message_fwd_from_name,
|
||||
|
|
|
|||
|
|
@ -115,6 +115,7 @@ INSERT INTO private_messages (
|
|||
quote_text,
|
||||
quote_entities,
|
||||
quote_offset,
|
||||
reply_external,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -139,6 +140,7 @@ INSERT INTO private_messages (
|
|||
sqlc.arg(quote_text)::text,
|
||||
sqlc.arg(quote_entities_json)::jsonb,
|
||||
sqlc.arg(quote_offset)::int,
|
||||
COALESCE(sqlc.arg(reply_external_json)::jsonb, '{}'::jsonb),
|
||||
sqlc.arg(fwd_from_peer_type)::text,
|
||||
sqlc.arg(fwd_from_peer_id)::bigint,
|
||||
sqlc.arg(fwd_from_name)::text,
|
||||
|
|
@ -216,6 +218,7 @@ INSERT INTO message_boxes (
|
|||
quote_text,
|
||||
quote_entities,
|
||||
quote_offset,
|
||||
reply_external,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -249,6 +252,7 @@ INSERT INTO message_boxes (
|
|||
sqlc.arg(quote_text)::text,
|
||||
sqlc.arg(quote_entities_json)::jsonb,
|
||||
sqlc.arg(quote_offset)::int,
|
||||
COALESCE(sqlc.arg(reply_external_json)::jsonb, '{}'::jsonb),
|
||||
sqlc.arg(fwd_from_peer_type)::text,
|
||||
sqlc.arg(fwd_from_peer_id)::bigint,
|
||||
sqlc.arg(fwd_from_name)::text,
|
||||
|
|
@ -293,6 +297,7 @@ RETURNING
|
|||
quote_text,
|
||||
quote_entities::text AS quote_entities_json,
|
||||
quote_offset,
|
||||
reply_external::text AS reply_external_json,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -339,6 +344,7 @@ SELECT
|
|||
quote_text,
|
||||
quote_entities::text AS quote_entities_json,
|
||||
quote_offset,
|
||||
reply_external::text AS reply_external_json,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -367,7 +373,9 @@ WHERE owner_user_id = $1
|
|||
SELECT
|
||||
box_id,
|
||||
private_message_id,
|
||||
message_sender_id
|
||||
message_sender_id,
|
||||
from_user_id, message_date, body, entities::text AS entities_json,
|
||||
media::text AS media_json, noforwards
|
||||
FROM message_boxes
|
||||
WHERE owner_user_id = sqlc.arg(owner_user_id)::bigint
|
||||
AND peer_type = sqlc.arg(peer_type)::text
|
||||
|
|
@ -410,6 +418,7 @@ SELECT
|
|||
m.quote_text,
|
||||
m.quote_entities::text AS quote_entities_json,
|
||||
m.quote_offset,
|
||||
m.reply_external::text AS reply_external_json,
|
||||
m.fwd_from_peer_type,
|
||||
m.fwd_from_peer_id,
|
||||
m.fwd_from_name,
|
||||
|
|
@ -479,6 +488,7 @@ base AS NOT MATERIALIZED (
|
|||
m.quote_text,
|
||||
m.quote_entities::text AS quote_entities_json,
|
||||
m.quote_offset,
|
||||
m.reply_external::text AS reply_external_json,
|
||||
m.fwd_from_peer_type,
|
||||
m.fwd_from_peer_id,
|
||||
m.fwd_from_name,
|
||||
|
|
@ -533,6 +543,7 @@ base AS NOT MATERIALIZED (
|
|||
LEFT JOIN users from_u ON from_u.id = m.from_user_id
|
||||
WHERE m.owner_user_id = $1
|
||||
AND NOT m.deleted
|
||||
AND (sqlc.arg(sender_user_id)::bigint = 0 OR m.from_user_id = sqlc.arg(sender_user_id)::bigint)
|
||||
AND (
|
||||
NOT sqlc.arg(has_peer)::boolean
|
||||
OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint)
|
||||
|
|
@ -590,11 +601,6 @@ base AS NOT MATERIALIZED (
|
|||
)
|
||||
)
|
||||
),
|
||||
total AS (
|
||||
SELECT count(*)::int AS total_count
|
||||
FROM base
|
||||
WHERE sqlc.arg(need_total_count)::boolean
|
||||
),
|
||||
backward AS (
|
||||
SELECT b.*
|
||||
FROM base b
|
||||
|
|
@ -617,7 +623,7 @@ around_forward AS (
|
|||
WHERE p.load_type = 'around'
|
||||
AND (
|
||||
(p.offset_date > 0 AND b.message_date >= p.offset_date)
|
||||
OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id)
|
||||
OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id >= p.offset_id)
|
||||
)
|
||||
ORDER BY b.box_id ASC
|
||||
LIMIT LEAST(-(SELECT add_offset FROM load_params), (SELECT limit_count FROM load_params))
|
||||
|
|
@ -630,7 +636,7 @@ around_backward AS (
|
|||
WHERE p.load_type = 'around'
|
||||
AND (
|
||||
(p.offset_date > 0 AND b.message_date < p.offset_date)
|
||||
OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id <= p.offset_id))
|
||||
OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id < p.offset_id))
|
||||
)
|
||||
ORDER BY b.box_id DESC
|
||||
LIMIT GREATEST((SELECT limit_count + add_offset FROM load_params), 0)
|
||||
|
|
@ -644,9 +650,10 @@ forward AS (
|
|||
WHERE p.load_type = 'forward'
|
||||
AND (
|
||||
(p.offset_date > 0 AND b.message_date >= p.offset_date)
|
||||
OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id)
|
||||
OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id >= p.offset_id)
|
||||
)
|
||||
ORDER BY b.box_id ASC
|
||||
OFFSET GREATEST((SELECT -add_offset - limit_count FROM load_params), 0)
|
||||
LIMIT (SELECT limit_count FROM load_params)
|
||||
) f
|
||||
),
|
||||
|
|
@ -684,6 +691,7 @@ SELECT
|
|||
quote_text,
|
||||
quote_entities_json,
|
||||
quote_offset,
|
||||
reply_external_json,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -732,17 +740,15 @@ SELECT
|
|||
from_user_premium_until,
|
||||
from_user_emoji_status_document_id,
|
||||
from_user_emoji_status_until,
|
||||
from_user_last_seen_at,
|
||||
COALESCE(total.total_count, 0)::int AS total_count
|
||||
from_user_last_seen_at
|
||||
FROM paged
|
||||
CROSS JOIN total
|
||||
ORDER BY box_id DESC;
|
||||
|
||||
-- name: ListMessagesBackward :many
|
||||
-- backward 热路径(add_offset>=0:初始加载/上滑翻页)的扁平静态查询。
|
||||
-- 与 ListMessagesByUser 的 backward 分支逐位等价(相同 base 过滤 + 相同 anchor +
|
||||
-- ORDER BY box_id DESC + OFFSET GREATEST(add_offset,0) + LIMIT),但只规划单次
|
||||
-- index scan + 2 LEFT JOIN,避免大 CTE 把 4 个分支+total 全树规划。total 走
|
||||
-- index scan + 2 LEFT JOIN,避免大 CTE 把 4 个分支全树规划。total 走
|
||||
-- 独立 CountMessagesByUser,仅 NeedTotalCount 时发。
|
||||
SELECT
|
||||
m.box_id,
|
||||
|
|
@ -769,6 +775,7 @@ SELECT
|
|||
m.quote_text,
|
||||
m.quote_entities::text AS quote_entities_json,
|
||||
m.quote_offset,
|
||||
m.reply_external::text AS reply_external_json,
|
||||
m.fwd_from_peer_type,
|
||||
m.fwd_from_peer_id,
|
||||
m.fwd_from_name,
|
||||
|
|
@ -823,6 +830,7 @@ LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id
|
|||
LEFT JOIN users from_u ON from_u.id = m.from_user_id
|
||||
WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
|
||||
AND NOT m.deleted
|
||||
AND (sqlc.arg(sender_user_id)::bigint = 0 OR m.from_user_id = sqlc.arg(sender_user_id)::bigint)
|
||||
AND (
|
||||
NOT sqlc.arg(has_peer)::boolean
|
||||
OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint)
|
||||
|
|
@ -894,6 +902,7 @@ SELECT count(*)::int AS total_count
|
|||
FROM message_boxes m
|
||||
WHERE m.owner_user_id = sqlc.arg(owner_user_id)::bigint
|
||||
AND NOT m.deleted
|
||||
AND (sqlc.arg(sender_user_id)::bigint = 0 OR m.from_user_id = sqlc.arg(sender_user_id)::bigint)
|
||||
AND (
|
||||
NOT sqlc.arg(has_peer)::boolean
|
||||
OR (m.peer_type = sqlc.arg(peer_type)::text AND m.peer_id = sqlc.arg(peer_id)::bigint)
|
||||
|
|
@ -978,6 +987,7 @@ SELECT
|
|||
m.quote_text,
|
||||
m.quote_entities::text AS quote_entities_json,
|
||||
m.quote_offset,
|
||||
m.reply_external::text AS reply_external_json,
|
||||
m.fwd_from_peer_type,
|
||||
m.fwd_from_peer_id,
|
||||
m.fwd_from_name,
|
||||
|
|
@ -1063,6 +1073,7 @@ SELECT
|
|||
quote_text,
|
||||
quote_entities::text AS quote_entities_json,
|
||||
quote_offset,
|
||||
reply_external::text AS reply_external_json,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -1118,6 +1129,7 @@ SELECT
|
|||
quote_text,
|
||||
quote_entities::text AS quote_entities_json,
|
||||
quote_offset,
|
||||
reply_external::text AS reply_external_json,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -1206,6 +1218,7 @@ RETURNING
|
|||
quote_text,
|
||||
quote_entities::text AS quote_entities_json,
|
||||
quote_offset,
|
||||
reply_external::text AS reply_external_json,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -1507,6 +1520,7 @@ SELECT
|
|||
quote_text,
|
||||
quote_entities::text AS quote_entities_json,
|
||||
quote_offset,
|
||||
reply_external::text AS reply_external_json,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
|
|||
|
|
@ -42,6 +42,7 @@ SELECT
|
|||
m.quote_text,
|
||||
m.quote_entities::text AS quote_entities_json,
|
||||
m.quote_offset,
|
||||
m.reply_external::text AS reply_external_json,
|
||||
m.fwd_from_peer_type,
|
||||
m.fwd_from_peer_id,
|
||||
m.fwd_from_name,
|
||||
|
|
@ -113,6 +114,7 @@ SELECT
|
|||
m.quote_text,
|
||||
m.quote_entities::text AS quote_entities_json,
|
||||
m.quote_offset,
|
||||
m.reply_external::text AS reply_external_json,
|
||||
m.fwd_from_peer_type,
|
||||
m.fwd_from_peer_id,
|
||||
m.fwd_from_name,
|
||||
|
|
@ -190,6 +192,7 @@ SELECT
|
|||
m.quote_text,
|
||||
m.quote_entities::text AS quote_entities_json,
|
||||
m.quote_offset,
|
||||
m.reply_external::text AS reply_external_json,
|
||||
m.fwd_from_peer_type,
|
||||
m.fwd_from_peer_id,
|
||||
m.fwd_from_name,
|
||||
|
|
|
|||
|
|
@ -103,6 +103,7 @@ SELECT
|
|||
COALESCE(m.quote_text, '')::text AS quote_text,
|
||||
COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json,
|
||||
COALESCE(m.quote_offset, 0)::int AS quote_offset,
|
||||
COALESCE(m.reply_external, '{}'::jsonb)::text AS reply_external_json,
|
||||
COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type,
|
||||
COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id,
|
||||
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
|
||||
|
|
@ -378,6 +379,7 @@ SELECT
|
|||
COALESCE(m.quote_text, '')::text AS quote_text,
|
||||
COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json,
|
||||
COALESCE(m.quote_offset, 0)::int AS quote_offset,
|
||||
COALESCE(m.reply_external, '{}'::jsonb)::text AS reply_external_json,
|
||||
COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type,
|
||||
COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id,
|
||||
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import (
|
|||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"telesrv/internal/domain"
|
||||
|
|
@ -227,7 +226,7 @@ func (l *ReadModelChangeListener) Run(ctx context.Context) {
|
|||
}
|
||||
|
||||
func (l *ReadModelChangeListener) listenAndConsume(ctx context.Context) error {
|
||||
conn, err := pgx.Connect(ctx, l.dsn)
|
||||
conn, err := connectPostgresAdmitted(ctx, l.dsn)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -377,6 +377,7 @@ type savedDialogTopRowFields struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson string
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson string
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -401,11 +402,11 @@ type savedDialogTopRowFields struct {
|
|||
func savedDialogRowFields[T sqlcgen.ListSavedDialogTopsRow | sqlcgen.ListPinnedSavedDialogTopsRow | sqlcgen.ListSavedDialogTopsByPeersRow](row T) savedDialogTopRowFields {
|
||||
switch r := any(row).(type) {
|
||||
case sqlcgen.ListSavedDialogTopsRow:
|
||||
return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.HideEdited, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned}
|
||||
return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.HideEdited, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.ReplyExternalJson, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned}
|
||||
case sqlcgen.ListPinnedSavedDialogTopsRow:
|
||||
return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.HideEdited, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned}
|
||||
return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.HideEdited, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.ReplyExternalJson, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned}
|
||||
case sqlcgen.ListSavedDialogTopsByPeersRow:
|
||||
return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.HideEdited, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned}
|
||||
return savedDialogTopRowFields{r.BoxID, r.PrivateMessageID, r.OwnerUserID, r.PeerType, r.PeerID, r.FromUserID, r.MessageDate, r.TtlPeriod, r.ExpiresAt, r.EditDate, r.HideEdited, r.Outgoing, r.Body, r.EntitiesJson, r.Silent, r.Noforwards, r.ReplyToMsgID, r.ReplyToPeerType, r.ReplyToPeerID, r.ReplyToTopID, r.ReplyToStoryID, r.QuoteText, r.QuoteEntitiesJson, r.QuoteOffset, r.ReplyExternalJson, r.FwdFromPeerType, r.FwdFromPeerID, r.FwdFromName, r.FwdDate, r.FwdSavedFromPeerType, r.FwdSavedFromPeerID, r.FwdSavedFromMsgID, r.SavedPeerType, r.SavedPeerID, r.Pts, r.MediaJson, r.MediaUnread, r.ReactionUnread, r.ViaBotID, r.GroupedID, r.Effect, r.ReplyMarkupJson, r.RichMessageJson, r.Pinned}
|
||||
}
|
||||
return savedDialogTopRowFields{}
|
||||
}
|
||||
|
|
@ -426,6 +427,7 @@ func messageFromSavedDialogRow(row savedDialogTopRowFields) (domain.Message, err
|
|||
row.QuoteText,
|
||||
row.QuoteEntitiesJson,
|
||||
row.QuoteOffset,
|
||||
row.ReplyExternalJson,
|
||||
row.FwdFromPeerType,
|
||||
row.FwdFromPeerID,
|
||||
row.FwdFromName,
|
||||
|
|
|
|||
|
|
@ -578,7 +578,7 @@ func scanScheduledMessage(scanner interface{ Scan(...any) error }) (domain.Sched
|
|||
_, _, reply, forward, err := messageMetadataFromFields(
|
||||
msg.Silent, msg.NoForwards, replyToMsgID, replyToPeerType, replyToPeerID, replyToTopID,
|
||||
0, // scheduled_messages 不持久化 story 回复(定时回复 story 是边角,恒 0)
|
||||
quoteText, quoteEntitiesJSON, quoteOffset,
|
||||
quoteText, quoteEntitiesJSON, quoteOffset, "{}",
|
||||
fwdFromPeerType, fwdFromPeerID, fwdFromName, fwdDate,
|
||||
"", 0, 0,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -727,6 +727,7 @@ base AS (
|
|||
COALESCE(m.quote_text, '')::text AS message_quote_text,
|
||||
COALESCE(m.quote_entities::text, '[]')::text AS message_quote_entities_json,
|
||||
COALESCE(m.quote_offset, 0)::int AS message_quote_offset,
|
||||
COALESCE(m.reply_external, '{}'::jsonb)::text AS message_reply_external_json,
|
||||
COALESCE(m.fwd_from_peer_type, '')::text AS message_fwd_from_peer_type,
|
||||
COALESCE(m.fwd_from_peer_id, 0)::bigint AS message_fwd_from_peer_id,
|
||||
COALESCE(m.fwd_from_name, '')::text AS message_fwd_from_name,
|
||||
|
|
@ -812,6 +813,7 @@ SELECT
|
|||
message_quote_text,
|
||||
message_quote_entities_json,
|
||||
message_quote_offset,
|
||||
message_reply_external_json,
|
||||
message_fwd_from_peer_type,
|
||||
message_fwd_from_peer_id,
|
||||
message_fwd_from_name,
|
||||
|
|
@ -897,6 +899,7 @@ type ListDialogsByPeersRow struct {
|
|||
MessageQuoteText string
|
||||
MessageQuoteEntitiesJson string
|
||||
MessageQuoteOffset int32
|
||||
MessageReplyExternalJson string
|
||||
MessageFwdFromPeerType string
|
||||
MessageFwdFromPeerID int64
|
||||
MessageFwdFromName string
|
||||
|
|
@ -983,6 +986,7 @@ func (q *Queries) ListDialogsByPeers(ctx context.Context, arg ListDialogsByPeers
|
|||
&i.MessageQuoteText,
|
||||
&i.MessageQuoteEntitiesJson,
|
||||
&i.MessageQuoteOffset,
|
||||
&i.MessageReplyExternalJson,
|
||||
&i.MessageFwdFromPeerType,
|
||||
&i.MessageFwdFromPeerID,
|
||||
&i.MessageFwdFromName,
|
||||
|
|
@ -1071,6 +1075,7 @@ WITH base AS (
|
|||
COALESCE(m.quote_text, '')::text AS message_quote_text,
|
||||
COALESCE(m.quote_entities::text, '[]')::text AS message_quote_entities_json,
|
||||
COALESCE(m.quote_offset, 0)::int AS message_quote_offset,
|
||||
COALESCE(m.reply_external, '{}'::jsonb)::text AS message_reply_external_json,
|
||||
COALESCE(m.fwd_from_peer_type, '')::text AS message_fwd_from_peer_type,
|
||||
COALESCE(m.fwd_from_peer_id, 0)::bigint AS message_fwd_from_peer_id,
|
||||
COALESCE(m.fwd_from_name, '')::text AS message_fwd_from_name,
|
||||
|
|
@ -1143,7 +1148,7 @@ WITH base AS (
|
|||
AND (NOT $16::boolean OR NOT d.pinned)
|
||||
),
|
||||
paged AS (
|
||||
SELECT user_id, peer_type, peer_id, folder_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mentions_count, unread_reactions_count, ttl_period, theme_emoticon, has_scheduled, pinned, pinned_order, unread_mark, hidden_peer_settings_bar, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, peer_contact, peer_mutual, message_id, message_private_message_id, message_from_user_id, message_date, message_outgoing, message_body, message_entities_json, message_media_json, message_ttl_period, message_expires_at, message_edit_date, message_hide_edited, message_silent, message_noforwards, message_reply_to_msg_id, message_reply_to_peer_type, message_reply_to_peer_id, message_reply_to_top_id, message_reply_to_story_id, message_quote_text, message_quote_entities_json, message_quote_offset, message_fwd_from_peer_type, message_fwd_from_peer_id, message_fwd_from_name, message_fwd_date, message_fwd_saved_from_peer_type, message_fwd_saved_from_peer_id, message_fwd_saved_from_msg_id, message_saved_peer_type, message_saved_peer_id, message_media_unread, message_reaction_unread, message_via_bot_id, message_grouped_id, message_effect, message_reply_markup_json, message_rich_message_json, message_pinned
|
||||
SELECT user_id, peer_type, peer_id, folder_id, top_message_id, top_message_date, read_inbox_max_id, read_outbox_max_id, unread_count, unread_mentions_count, unread_reactions_count, ttl_period, theme_emoticon, has_scheduled, pinned, pinned_order, unread_mark, hidden_peer_settings_bar, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, peer_contact, peer_mutual, message_id, message_private_message_id, message_from_user_id, message_date, message_outgoing, message_body, message_entities_json, message_media_json, message_ttl_period, message_expires_at, message_edit_date, message_hide_edited, message_silent, message_noforwards, message_reply_to_msg_id, message_reply_to_peer_type, message_reply_to_peer_id, message_reply_to_top_id, message_reply_to_story_id, message_quote_text, message_quote_entities_json, message_quote_offset, message_reply_external_json, message_fwd_from_peer_type, message_fwd_from_peer_id, message_fwd_from_name, message_fwd_date, message_fwd_saved_from_peer_type, message_fwd_saved_from_peer_id, message_fwd_saved_from_msg_id, message_saved_peer_type, message_saved_peer_id, message_media_unread, message_reaction_unread, message_via_bot_id, message_grouped_id, message_effect, message_reply_markup_json, message_rich_message_json, message_pinned
|
||||
FROM base
|
||||
WHERE (
|
||||
($17::int <= 0 AND $18::int <= 0)
|
||||
|
|
@ -1237,6 +1242,7 @@ SELECT
|
|||
message_quote_text,
|
||||
message_quote_entities_json,
|
||||
message_quote_offset,
|
||||
message_reply_external_json,
|
||||
message_fwd_from_peer_type,
|
||||
message_fwd_from_peer_id,
|
||||
message_fwd_from_name,
|
||||
|
|
@ -1345,6 +1351,7 @@ type ListDialogsByUserRow struct {
|
|||
MessageQuoteText string
|
||||
MessageQuoteEntitiesJson string
|
||||
MessageQuoteOffset int32
|
||||
MessageReplyExternalJson string
|
||||
MessageFwdFromPeerType string
|
||||
MessageFwdFromPeerID int64
|
||||
MessageFwdFromName string
|
||||
|
|
@ -1452,6 +1459,7 @@ func (q *Queries) ListDialogsByUser(ctx context.Context, arg ListDialogsByUserPa
|
|||
&i.MessageQuoteText,
|
||||
&i.MessageQuoteEntitiesJson,
|
||||
&i.MessageQuoteOffset,
|
||||
&i.MessageReplyExternalJson,
|
||||
&i.MessageFwdFromPeerType,
|
||||
&i.MessageFwdFromPeerID,
|
||||
&i.MessageFwdFromName,
|
||||
|
|
|
|||
|
|
@ -14,25 +14,26 @@ SELECT count(*)::int AS total_count
|
|||
FROM message_boxes m
|
||||
WHERE m.owner_user_id = $1::bigint
|
||||
AND NOT m.deleted
|
||||
AND ($2::bigint = 0 OR m.from_user_id = $2::bigint)
|
||||
AND (
|
||||
NOT $2::boolean
|
||||
OR (m.peer_type = $3::text AND m.peer_id = $4::bigint)
|
||||
NOT $3::boolean
|
||||
OR (m.peer_type = $4::text AND m.peer_id = $5::bigint)
|
||||
)
|
||||
AND (
|
||||
NOT $5::boolean
|
||||
OR (m.peer_type = 'user' AND m.peer_id = ANY($6::bigint[]))
|
||||
NOT $6::boolean
|
||||
OR (m.peer_type = 'user' AND m.peer_id = ANY($7::bigint[]))
|
||||
)
|
||||
AND (
|
||||
$7::text = ''
|
||||
OR m.body ILIKE ('%' || $7::text || '%')
|
||||
$8::text = ''
|
||||
OR m.body ILIKE ('%' || $8::text || '%')
|
||||
)
|
||||
AND ($8::int <= 0 OR m.message_date > $8::int)
|
||||
AND ($9::int <= 0 OR m.message_date < $9::int)
|
||||
AND ($10::int <= 0 OR m.box_id < $10::int)
|
||||
AND ($11::int <= 0 OR m.box_id > $11::int)
|
||||
AND (NOT $12::boolean OR m.pinned)
|
||||
AND ($9::int <= 0 OR m.message_date > $9::int)
|
||||
AND ($10::int <= 0 OR m.message_date < $10::int)
|
||||
AND ($11::int <= 0 OR m.box_id < $11::int)
|
||||
AND ($12::int <= 0 OR m.box_id > $12::int)
|
||||
AND (NOT $13::boolean OR m.pinned)
|
||||
AND (
|
||||
NOT $13::boolean
|
||||
NOT $14::boolean
|
||||
OR (
|
||||
m.media->>'kind' = 'document'
|
||||
AND EXISTS (
|
||||
|
|
@ -44,11 +45,11 @@ WHERE m.owner_user_id = $1::bigint
|
|||
)
|
||||
)
|
||||
AND (
|
||||
NOT $14::boolean
|
||||
NOT $15::boolean
|
||||
OR m.media #>> '{service_action,kind}' = 'phone_call'
|
||||
)
|
||||
AND (
|
||||
NOT $15::boolean
|
||||
NOT $16::boolean
|
||||
OR (
|
||||
NOT m.outgoing
|
||||
AND m.media #>> '{service_action,kind}' = 'phone_call'
|
||||
|
|
@ -56,24 +57,25 @@ WHERE m.owner_user_id = $1::bigint
|
|||
)
|
||||
)
|
||||
AND (
|
||||
$16::text = ''
|
||||
OR (m.saved_peer_type = $16::text AND m.saved_peer_id = $17::bigint)
|
||||
$17::text = ''
|
||||
OR (m.saved_peer_type = $17::text AND m.saved_peer_id = $18::bigint)
|
||||
)
|
||||
AND (
|
||||
cardinality($18::text[]) = 0
|
||||
cardinality($19::text[]) = 0
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM saved_message_reaction_tags tag
|
||||
WHERE tag.user_id = m.owner_user_id
|
||||
AND tag.message_box_id = m.box_id
|
||||
AND (tag.reaction_type || ':' || tag.reaction_value)
|
||||
= ANY($18::text[])
|
||||
= ANY($19::text[])
|
||||
)
|
||||
)
|
||||
`
|
||||
|
||||
type CountMessagesByUserParams struct {
|
||||
OwnerUserID int64
|
||||
SenderUserID int64
|
||||
HasPeer bool
|
||||
PeerType string
|
||||
PeerID int64
|
||||
|
|
@ -98,6 +100,7 @@ type CountMessagesByUserParams struct {
|
|||
func (q *Queries) CountMessagesByUser(ctx context.Context, arg CountMessagesByUserParams) (int32, error) {
|
||||
row := q.db.QueryRow(ctx, countMessagesByUser,
|
||||
arg.OwnerUserID,
|
||||
arg.SenderUserID,
|
||||
arg.HasPeer,
|
||||
arg.PeerType,
|
||||
arg.PeerID,
|
||||
|
|
@ -299,6 +302,7 @@ INSERT INTO message_boxes (
|
|||
quote_text,
|
||||
quote_entities,
|
||||
quote_offset,
|
||||
reply_external,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -332,24 +336,25 @@ INSERT INTO message_boxes (
|
|||
$21::text,
|
||||
$22::jsonb,
|
||||
$23::int,
|
||||
$24::text,
|
||||
$25::bigint,
|
||||
$26::text,
|
||||
$27::int,
|
||||
$28::text,
|
||||
$29::bigint,
|
||||
$30::int,
|
||||
$31::text,
|
||||
$32::bigint,
|
||||
$33::int,
|
||||
$34::jsonb,
|
||||
$35::boolean,
|
||||
COALESCE($24::jsonb, '{}'::jsonb),
|
||||
$25::text,
|
||||
$26::bigint,
|
||||
$27::text,
|
||||
$28::int,
|
||||
$29::text,
|
||||
$30::bigint,
|
||||
$31::int,
|
||||
$32::text,
|
||||
$33::bigint,
|
||||
$34::int,
|
||||
$35::jsonb,
|
||||
$36::boolean,
|
||||
$37::jsonb,
|
||||
$37::boolean,
|
||||
$38::jsonb,
|
||||
$39::bigint,
|
||||
$39::jsonb,
|
||||
$40::bigint,
|
||||
$41::bigint
|
||||
$41::bigint,
|
||||
$42::bigint
|
||||
)
|
||||
RETURNING
|
||||
box_id,
|
||||
|
|
@ -376,6 +381,7 @@ RETURNING
|
|||
quote_text,
|
||||
quote_entities::text AS quote_entities_json,
|
||||
quote_offset,
|
||||
reply_external::text AS reply_external_json,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -421,6 +427,7 @@ type CreateMessageBoxParams struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson []byte
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson []byte
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -466,6 +473,7 @@ type CreateMessageBoxRow struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson string
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson string
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -512,6 +520,7 @@ func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxPara
|
|||
arg.QuoteText,
|
||||
arg.QuoteEntitiesJson,
|
||||
arg.QuoteOffset,
|
||||
arg.ReplyExternalJson,
|
||||
arg.FwdFromPeerType,
|
||||
arg.FwdFromPeerID,
|
||||
arg.FwdFromName,
|
||||
|
|
@ -557,6 +566,7 @@ func (q *Queries) CreateMessageBox(ctx context.Context, arg CreateMessageBoxPara
|
|||
&i.QuoteText,
|
||||
&i.QuoteEntitiesJson,
|
||||
&i.QuoteOffset,
|
||||
&i.ReplyExternalJson,
|
||||
&i.FwdFromPeerType,
|
||||
&i.FwdFromPeerID,
|
||||
&i.FwdFromName,
|
||||
|
|
@ -606,6 +616,7 @@ INSERT INTO private_messages (
|
|||
quote_text,
|
||||
quote_entities,
|
||||
quote_offset,
|
||||
reply_external,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -630,16 +641,17 @@ INSERT INTO private_messages (
|
|||
$18::text,
|
||||
$19::jsonb,
|
||||
$20::int,
|
||||
$21::text,
|
||||
$22::bigint,
|
||||
$23::text,
|
||||
$24::int,
|
||||
$25::jsonb,
|
||||
COALESCE($21::jsonb, '{}'::jsonb),
|
||||
$22::text,
|
||||
$23::bigint,
|
||||
$24::text,
|
||||
$25::int,
|
||||
$26::jsonb,
|
||||
$27::jsonb,
|
||||
$28::bigint,
|
||||
$28::jsonb,
|
||||
$29::bigint,
|
||||
$30::bigint
|
||||
$30::bigint,
|
||||
$31::bigint
|
||||
)
|
||||
ON CONFLICT (sender_user_id, random_id) WHERE random_id <> 0 DO NOTHING
|
||||
RETURNING
|
||||
|
|
@ -676,6 +688,7 @@ type CreatePrivateMessageParams struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson []byte
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson []byte
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -723,6 +736,7 @@ func (q *Queries) CreatePrivateMessage(ctx context.Context, arg CreatePrivateMes
|
|||
arg.QuoteText,
|
||||
arg.QuoteEntitiesJson,
|
||||
arg.QuoteOffset,
|
||||
arg.ReplyExternalJson,
|
||||
arg.FwdFromPeerType,
|
||||
arg.FwdFromPeerID,
|
||||
arg.FwdFromName,
|
||||
|
|
@ -1147,6 +1161,7 @@ SELECT
|
|||
quote_text,
|
||||
quote_entities::text AS quote_entities_json,
|
||||
quote_offset,
|
||||
reply_external::text AS reply_external_json,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -1202,6 +1217,7 @@ type GetMessageBoxByPrivateMessageRow struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson string
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson string
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -1251,6 +1267,7 @@ func (q *Queries) GetMessageBoxByPrivateMessage(ctx context.Context, arg GetMess
|
|||
&i.QuoteText,
|
||||
&i.QuoteEntitiesJson,
|
||||
&i.QuoteOffset,
|
||||
&i.ReplyExternalJson,
|
||||
&i.FwdFromPeerType,
|
||||
&i.FwdFromPeerID,
|
||||
&i.FwdFromName,
|
||||
|
|
@ -1301,6 +1318,7 @@ SELECT
|
|||
quote_text,
|
||||
quote_entities::text AS quote_entities_json,
|
||||
quote_offset,
|
||||
reply_external::text AS reply_external_json,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -1363,6 +1381,7 @@ type GetMessageBoxForEditRow struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson string
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson string
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -1418,6 +1437,7 @@ func (q *Queries) GetMessageBoxForEdit(ctx context.Context, arg GetMessageBoxFor
|
|||
&i.QuoteText,
|
||||
&i.QuoteEntitiesJson,
|
||||
&i.QuoteOffset,
|
||||
&i.ReplyExternalJson,
|
||||
&i.FwdFromPeerType,
|
||||
&i.FwdFromPeerID,
|
||||
&i.FwdFromName,
|
||||
|
|
@ -1495,7 +1515,9 @@ const getMessageBoxForReply = `-- name: GetMessageBoxForReply :one
|
|||
SELECT
|
||||
box_id,
|
||||
private_message_id,
|
||||
message_sender_id
|
||||
message_sender_id,
|
||||
from_user_id, message_date, body, entities::text AS entities_json,
|
||||
media::text AS media_json, noforwards
|
||||
FROM message_boxes
|
||||
WHERE owner_user_id = $1::bigint
|
||||
AND peer_type = $2::text
|
||||
|
|
@ -1516,6 +1538,12 @@ type GetMessageBoxForReplyRow struct {
|
|||
BoxID int32
|
||||
PrivateMessageID int64
|
||||
MessageSenderID int64
|
||||
FromUserID int64
|
||||
MessageDate int32
|
||||
Body string
|
||||
EntitiesJson string
|
||||
MediaJson string
|
||||
Noforwards bool
|
||||
}
|
||||
|
||||
func (q *Queries) GetMessageBoxForReply(ctx context.Context, arg GetMessageBoxForReplyParams) (GetMessageBoxForReplyRow, error) {
|
||||
|
|
@ -1526,7 +1554,17 @@ func (q *Queries) GetMessageBoxForReply(ctx context.Context, arg GetMessageBoxFo
|
|||
arg.BoxID,
|
||||
)
|
||||
var i GetMessageBoxForReplyRow
|
||||
err := row.Scan(&i.BoxID, &i.PrivateMessageID, &i.MessageSenderID)
|
||||
err := row.Scan(
|
||||
&i.BoxID,
|
||||
&i.PrivateMessageID,
|
||||
&i.MessageSenderID,
|
||||
&i.FromUserID,
|
||||
&i.MessageDate,
|
||||
&i.Body,
|
||||
&i.EntitiesJson,
|
||||
&i.MediaJson,
|
||||
&i.Noforwards,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
|
|
@ -1557,6 +1595,7 @@ SELECT
|
|||
m.quote_text,
|
||||
m.quote_entities::text AS quote_entities_json,
|
||||
m.quote_offset,
|
||||
m.reply_external::text AS reply_external_json,
|
||||
m.fwd_from_peer_type,
|
||||
m.fwd_from_peer_id,
|
||||
m.fwd_from_name,
|
||||
|
|
@ -1647,6 +1686,7 @@ type GetMessageBoxesByIDsRow struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson string
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson string
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -1733,6 +1773,7 @@ func (q *Queries) GetMessageBoxesByIDs(ctx context.Context, arg GetMessageBoxesB
|
|||
&i.QuoteText,
|
||||
&i.QuoteEntitiesJson,
|
||||
&i.QuoteOffset,
|
||||
&i.ReplyExternalJson,
|
||||
&i.FwdFromPeerType,
|
||||
&i.FwdFromPeerID,
|
||||
&i.FwdFromName,
|
||||
|
|
@ -1827,6 +1868,7 @@ SELECT
|
|||
m.quote_text,
|
||||
m.quote_entities::text AS quote_entities_json,
|
||||
m.quote_offset,
|
||||
m.reply_external::text AS reply_external_json,
|
||||
m.fwd_from_peer_type,
|
||||
m.fwd_from_peer_id,
|
||||
m.fwd_from_name,
|
||||
|
|
@ -1887,6 +1929,7 @@ type GetMessageBoxesForForwardRow struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson string
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson string
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -1946,6 +1989,7 @@ func (q *Queries) GetMessageBoxesForForward(ctx context.Context, arg GetMessageB
|
|||
&i.QuoteText,
|
||||
&i.QuoteEntitiesJson,
|
||||
&i.QuoteOffset,
|
||||
&i.ReplyExternalJson,
|
||||
&i.FwdFromPeerType,
|
||||
&i.FwdFromPeerID,
|
||||
&i.FwdFromName,
|
||||
|
|
@ -2270,6 +2314,7 @@ SELECT
|
|||
m.quote_text,
|
||||
m.quote_entities::text AS quote_entities_json,
|
||||
m.quote_offset,
|
||||
m.reply_external::text AS reply_external_json,
|
||||
m.fwd_from_peer_type,
|
||||
m.fwd_from_peer_id,
|
||||
m.fwd_from_name,
|
||||
|
|
@ -2324,25 +2369,26 @@ LEFT JOIN users peer_u ON m.peer_type = 'user' AND peer_u.id = m.peer_id
|
|||
LEFT JOIN users from_u ON from_u.id = m.from_user_id
|
||||
WHERE m.owner_user_id = $1::bigint
|
||||
AND NOT m.deleted
|
||||
AND ($2::bigint = 0 OR m.from_user_id = $2::bigint)
|
||||
AND (
|
||||
NOT $2::boolean
|
||||
OR (m.peer_type = $3::text AND m.peer_id = $4::bigint)
|
||||
NOT $3::boolean
|
||||
OR (m.peer_type = $4::text AND m.peer_id = $5::bigint)
|
||||
)
|
||||
AND (
|
||||
NOT $5::boolean
|
||||
OR (m.peer_type = 'user' AND m.peer_id = ANY($6::bigint[]))
|
||||
NOT $6::boolean
|
||||
OR (m.peer_type = 'user' AND m.peer_id = ANY($7::bigint[]))
|
||||
)
|
||||
AND (
|
||||
$7::text = ''
|
||||
OR m.body ILIKE ('%' || $7::text || '%')
|
||||
$8::text = ''
|
||||
OR m.body ILIKE ('%' || $8::text || '%')
|
||||
)
|
||||
AND ($8::int <= 0 OR m.message_date > $8::int)
|
||||
AND ($9::int <= 0 OR m.message_date < $9::int)
|
||||
AND ($10::int <= 0 OR m.box_id < $10::int)
|
||||
AND ($11::int <= 0 OR m.box_id > $11::int)
|
||||
AND (NOT $12::boolean OR m.pinned)
|
||||
AND ($9::int <= 0 OR m.message_date > $9::int)
|
||||
AND ($10::int <= 0 OR m.message_date < $10::int)
|
||||
AND ($11::int <= 0 OR m.box_id < $11::int)
|
||||
AND ($12::int <= 0 OR m.box_id > $12::int)
|
||||
AND (NOT $13::boolean OR m.pinned)
|
||||
AND (
|
||||
NOT $13::boolean
|
||||
NOT $14::boolean
|
||||
OR (
|
||||
m.media->>'kind' = 'document'
|
||||
AND EXISTS (
|
||||
|
|
@ -2354,11 +2400,11 @@ WHERE m.owner_user_id = $1::bigint
|
|||
)
|
||||
)
|
||||
AND (
|
||||
NOT $14::boolean
|
||||
NOT $15::boolean
|
||||
OR m.media #>> '{service_action,kind}' = 'phone_call'
|
||||
)
|
||||
AND (
|
||||
NOT $15::boolean
|
||||
NOT $16::boolean
|
||||
OR (
|
||||
NOT m.outgoing
|
||||
AND m.media #>> '{service_action,kind}' = 'phone_call'
|
||||
|
|
@ -2366,31 +2412,32 @@ WHERE m.owner_user_id = $1::bigint
|
|||
)
|
||||
)
|
||||
AND (
|
||||
$16::text = ''
|
||||
OR (m.saved_peer_type = $16::text AND m.saved_peer_id = $17::bigint)
|
||||
$17::text = ''
|
||||
OR (m.saved_peer_type = $17::text AND m.saved_peer_id = $18::bigint)
|
||||
)
|
||||
AND (
|
||||
cardinality($18::text[]) = 0
|
||||
cardinality($19::text[]) = 0
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM saved_message_reaction_tags tag
|
||||
WHERE tag.user_id = m.owner_user_id
|
||||
AND tag.message_box_id = m.box_id
|
||||
AND (tag.reaction_type || ':' || tag.reaction_value)
|
||||
= ANY($18::text[])
|
||||
= ANY($19::text[])
|
||||
)
|
||||
)
|
||||
AND (
|
||||
($19::int > 0 AND m.message_date < $19::int)
|
||||
OR ($19::int <= 0 AND ($20::int <= 0 OR m.box_id < $20::int))
|
||||
($20::int > 0 AND m.message_date < $20::int)
|
||||
OR ($20::int <= 0 AND ($21::int <= 0 OR m.box_id < $21::int))
|
||||
)
|
||||
ORDER BY m.box_id DESC
|
||||
OFFSET GREATEST($21::int, 0)
|
||||
LIMIT $22::int
|
||||
OFFSET GREATEST($22::int, 0)
|
||||
LIMIT $23::int
|
||||
`
|
||||
|
||||
type ListMessagesBackwardParams struct {
|
||||
OwnerUserID int64
|
||||
SenderUserID int64
|
||||
HasPeer bool
|
||||
PeerType string
|
||||
PeerID int64
|
||||
|
|
@ -2439,6 +2486,7 @@ type ListMessagesBackwardRow struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson string
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson string
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -2493,11 +2541,12 @@ type ListMessagesBackwardRow struct {
|
|||
// backward 热路径(add_offset>=0:初始加载/上滑翻页)的扁平静态查询。
|
||||
// 与 ListMessagesByUser 的 backward 分支逐位等价(相同 base 过滤 + 相同 anchor +
|
||||
// ORDER BY box_id DESC + OFFSET GREATEST(add_offset,0) + LIMIT),但只规划单次
|
||||
// index scan + 2 LEFT JOIN,避免大 CTE 把 4 个分支+total 全树规划。total 走
|
||||
// index scan + 2 LEFT JOIN,避免大 CTE 把 4 个分支全树规划。total 走
|
||||
// 独立 CountMessagesByUser,仅 NeedTotalCount 时发。
|
||||
func (q *Queries) ListMessagesBackward(ctx context.Context, arg ListMessagesBackwardParams) ([]ListMessagesBackwardRow, error) {
|
||||
rows, err := q.db.Query(ctx, listMessagesBackward,
|
||||
arg.OwnerUserID,
|
||||
arg.SenderUserID,
|
||||
arg.HasPeer,
|
||||
arg.PeerType,
|
||||
arg.PeerID,
|
||||
|
|
@ -2552,6 +2601,7 @@ func (q *Queries) ListMessagesBackward(ctx context.Context, arg ListMessagesBack
|
|||
&i.QuoteText,
|
||||
&i.QuoteEntitiesJson,
|
||||
&i.QuoteOffset,
|
||||
&i.ReplyExternalJson,
|
||||
&i.FwdFromPeerType,
|
||||
&i.FwdFromPeerID,
|
||||
&i.FwdFromName,
|
||||
|
|
@ -2651,6 +2701,7 @@ base AS NOT MATERIALIZED (
|
|||
m.quote_text,
|
||||
m.quote_entities::text AS quote_entities_json,
|
||||
m.quote_offset,
|
||||
m.reply_external::text AS reply_external_json,
|
||||
m.fwd_from_peer_type,
|
||||
m.fwd_from_peer_id,
|
||||
m.fwd_from_name,
|
||||
|
|
@ -2705,25 +2756,26 @@ base AS NOT MATERIALIZED (
|
|||
LEFT JOIN users from_u ON from_u.id = m.from_user_id
|
||||
WHERE m.owner_user_id = $1
|
||||
AND NOT m.deleted
|
||||
AND ($6::bigint = 0 OR m.from_user_id = $6::bigint)
|
||||
AND (
|
||||
NOT $6::boolean
|
||||
OR (m.peer_type = $7::text AND m.peer_id = $8::bigint)
|
||||
NOT $7::boolean
|
||||
OR (m.peer_type = $8::text AND m.peer_id = $9::bigint)
|
||||
)
|
||||
AND (
|
||||
NOT $9::boolean
|
||||
OR (m.peer_type = 'user' AND m.peer_id = ANY($10::bigint[]))
|
||||
NOT $10::boolean
|
||||
OR (m.peer_type = 'user' AND m.peer_id = ANY($11::bigint[]))
|
||||
)
|
||||
AND (
|
||||
$11::text = ''
|
||||
OR m.body ILIKE ('%' || $11::text || '%')
|
||||
$12::text = ''
|
||||
OR m.body ILIKE ('%' || $12::text || '%')
|
||||
)
|
||||
AND ($12::int <= 0 OR m.message_date > $12::int)
|
||||
AND ($13::int <= 0 OR m.message_date < $13::int)
|
||||
AND ($14::int <= 0 OR m.box_id < $14::int)
|
||||
AND ($15::int <= 0 OR m.box_id > $15::int)
|
||||
AND (NOT $16::boolean OR m.pinned)
|
||||
AND ($13::int <= 0 OR m.message_date > $13::int)
|
||||
AND ($14::int <= 0 OR m.message_date < $14::int)
|
||||
AND ($15::int <= 0 OR m.box_id < $15::int)
|
||||
AND ($16::int <= 0 OR m.box_id > $16::int)
|
||||
AND (NOT $17::boolean OR m.pinned)
|
||||
AND (
|
||||
NOT $17::boolean
|
||||
NOT $18::boolean
|
||||
OR (
|
||||
m.media->>'kind' = 'document'
|
||||
AND EXISTS (
|
||||
|
|
@ -2735,11 +2787,11 @@ base AS NOT MATERIALIZED (
|
|||
)
|
||||
)
|
||||
AND (
|
||||
NOT $18::boolean
|
||||
NOT $19::boolean
|
||||
OR m.media #>> '{service_action,kind}' = 'phone_call'
|
||||
)
|
||||
AND (
|
||||
NOT $19::boolean
|
||||
NOT $20::boolean
|
||||
OR (
|
||||
NOT m.outgoing
|
||||
AND m.media #>> '{service_action,kind}' = 'phone_call'
|
||||
|
|
@ -2747,28 +2799,23 @@ base AS NOT MATERIALIZED (
|
|||
)
|
||||
)
|
||||
AND (
|
||||
$20::text = ''
|
||||
OR (m.saved_peer_type = $20::text AND m.saved_peer_id = $21::bigint)
|
||||
$21::text = ''
|
||||
OR (m.saved_peer_type = $21::text AND m.saved_peer_id = $22::bigint)
|
||||
)
|
||||
AND (
|
||||
cardinality($22::text[]) = 0
|
||||
cardinality($23::text[]) = 0
|
||||
OR EXISTS (
|
||||
SELECT 1
|
||||
FROM saved_message_reaction_tags tag
|
||||
WHERE tag.user_id = m.owner_user_id
|
||||
AND tag.message_box_id = m.box_id
|
||||
AND (tag.reaction_type || ':' || tag.reaction_value)
|
||||
= ANY($22::text[])
|
||||
= ANY($23::text[])
|
||||
)
|
||||
)
|
||||
),
|
||||
total AS (
|
||||
SELECT count(*)::int AS total_count
|
||||
FROM base
|
||||
WHERE $23::boolean
|
||||
),
|
||||
backward AS (
|
||||
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at
|
||||
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.reply_external_json, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at
|
||||
FROM base b
|
||||
CROSS JOIN load_params p
|
||||
WHERE p.load_type = 'backward'
|
||||
|
|
@ -2781,55 +2828,56 @@ backward AS (
|
|||
LIMIT (SELECT limit_count FROM load_params)
|
||||
),
|
||||
around_forward AS (
|
||||
SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.ttl_period, f.expires_at, f.edit_date, f.hide_edited, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.reply_to_story_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.fwd_saved_from_peer_type, f.fwd_saved_from_peer_id, f.fwd_saved_from_msg_id, f.saved_peer_type, f.saved_peer_id, f.pts, f.media_json, f.media_unread, f.reaction_unread, f.pinned, f.via_bot_id, f.grouped_id, f.effect, f.reply_markup_json, f.rich_message_json, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_is_bot, f.peer_bot_info_version, f.peer_premium_until, f.peer_emoji_status_document_id, f.peer_emoji_status_until, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_is_bot, f.from_user_bot_info_version, f.from_user_premium_until, f.from_user_emoji_status_document_id, f.from_user_emoji_status_until, f.from_user_last_seen_at
|
||||
SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.ttl_period, f.expires_at, f.edit_date, f.hide_edited, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.reply_to_story_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.reply_external_json, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.fwd_saved_from_peer_type, f.fwd_saved_from_peer_id, f.fwd_saved_from_msg_id, f.saved_peer_type, f.saved_peer_id, f.pts, f.media_json, f.media_unread, f.reaction_unread, f.pinned, f.via_bot_id, f.grouped_id, f.effect, f.reply_markup_json, f.rich_message_json, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_is_bot, f.peer_bot_info_version, f.peer_premium_until, f.peer_emoji_status_document_id, f.peer_emoji_status_until, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_is_bot, f.from_user_bot_info_version, f.from_user_premium_until, f.from_user_emoji_status_document_id, f.from_user_emoji_status_until, f.from_user_last_seen_at
|
||||
FROM (
|
||||
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at
|
||||
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.reply_external_json, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at
|
||||
FROM base b
|
||||
CROSS JOIN load_params p
|
||||
WHERE p.load_type = 'around'
|
||||
AND (
|
||||
(p.offset_date > 0 AND b.message_date >= p.offset_date)
|
||||
OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id)
|
||||
OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id >= p.offset_id)
|
||||
)
|
||||
ORDER BY b.box_id ASC
|
||||
LIMIT LEAST(-(SELECT add_offset FROM load_params), (SELECT limit_count FROM load_params))
|
||||
) f
|
||||
),
|
||||
around_backward AS (
|
||||
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at
|
||||
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.reply_external_json, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at
|
||||
FROM base b
|
||||
CROSS JOIN load_params p
|
||||
WHERE p.load_type = 'around'
|
||||
AND (
|
||||
(p.offset_date > 0 AND b.message_date < p.offset_date)
|
||||
OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id <= p.offset_id))
|
||||
OR (p.offset_date <= 0 AND (p.offset_id <= 0 OR b.box_id < p.offset_id))
|
||||
)
|
||||
ORDER BY b.box_id DESC
|
||||
LIMIT GREATEST((SELECT limit_count + add_offset FROM load_params), 0)
|
||||
),
|
||||
forward AS (
|
||||
SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.ttl_period, f.expires_at, f.edit_date, f.hide_edited, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.reply_to_story_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.fwd_saved_from_peer_type, f.fwd_saved_from_peer_id, f.fwd_saved_from_msg_id, f.saved_peer_type, f.saved_peer_id, f.pts, f.media_json, f.media_unread, f.reaction_unread, f.pinned, f.via_bot_id, f.grouped_id, f.effect, f.reply_markup_json, f.rich_message_json, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_is_bot, f.peer_bot_info_version, f.peer_premium_until, f.peer_emoji_status_document_id, f.peer_emoji_status_until, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_is_bot, f.from_user_bot_info_version, f.from_user_premium_until, f.from_user_emoji_status_document_id, f.from_user_emoji_status_until, f.from_user_last_seen_at
|
||||
SELECT f.box_id, f.private_message_id, f.owner_user_id, f.peer_type, f.peer_id, f.from_user_id, f.message_date, f.ttl_period, f.expires_at, f.edit_date, f.hide_edited, f.outgoing, f.body, f.entities_json, f.silent, f.noforwards, f.reply_to_msg_id, f.reply_to_peer_type, f.reply_to_peer_id, f.reply_to_top_id, f.reply_to_story_id, f.quote_text, f.quote_entities_json, f.quote_offset, f.reply_external_json, f.fwd_from_peer_type, f.fwd_from_peer_id, f.fwd_from_name, f.fwd_date, f.fwd_saved_from_peer_type, f.fwd_saved_from_peer_id, f.fwd_saved_from_msg_id, f.saved_peer_type, f.saved_peer_id, f.pts, f.media_json, f.media_unread, f.reaction_unread, f.pinned, f.via_bot_id, f.grouped_id, f.effect, f.reply_markup_json, f.rich_message_json, f.peer_user_id, f.peer_access_hash, f.peer_phone, f.peer_first_name, f.peer_last_name, f.peer_username, f.peer_country_code, f.peer_verified, f.peer_support, f.peer_is_bot, f.peer_bot_info_version, f.peer_premium_until, f.peer_emoji_status_document_id, f.peer_emoji_status_until, f.peer_last_seen_at, f.from_user_user_id, f.from_user_access_hash, f.from_user_phone, f.from_user_first_name, f.from_user_last_name, f.from_user_username, f.from_user_country_code, f.from_user_verified, f.from_user_support, f.from_user_is_bot, f.from_user_bot_info_version, f.from_user_premium_until, f.from_user_emoji_status_document_id, f.from_user_emoji_status_until, f.from_user_last_seen_at
|
||||
FROM (
|
||||
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at
|
||||
SELECT b.box_id, b.private_message_id, b.owner_user_id, b.peer_type, b.peer_id, b.from_user_id, b.message_date, b.ttl_period, b.expires_at, b.edit_date, b.hide_edited, b.outgoing, b.body, b.entities_json, b.silent, b.noforwards, b.reply_to_msg_id, b.reply_to_peer_type, b.reply_to_peer_id, b.reply_to_top_id, b.reply_to_story_id, b.quote_text, b.quote_entities_json, b.quote_offset, b.reply_external_json, b.fwd_from_peer_type, b.fwd_from_peer_id, b.fwd_from_name, b.fwd_date, b.fwd_saved_from_peer_type, b.fwd_saved_from_peer_id, b.fwd_saved_from_msg_id, b.saved_peer_type, b.saved_peer_id, b.pts, b.media_json, b.media_unread, b.reaction_unread, b.pinned, b.via_bot_id, b.grouped_id, b.effect, b.reply_markup_json, b.rich_message_json, b.peer_user_id, b.peer_access_hash, b.peer_phone, b.peer_first_name, b.peer_last_name, b.peer_username, b.peer_country_code, b.peer_verified, b.peer_support, b.peer_is_bot, b.peer_bot_info_version, b.peer_premium_until, b.peer_emoji_status_document_id, b.peer_emoji_status_until, b.peer_last_seen_at, b.from_user_user_id, b.from_user_access_hash, b.from_user_phone, b.from_user_first_name, b.from_user_last_name, b.from_user_username, b.from_user_country_code, b.from_user_verified, b.from_user_support, b.from_user_is_bot, b.from_user_bot_info_version, b.from_user_premium_until, b.from_user_emoji_status_document_id, b.from_user_emoji_status_until, b.from_user_last_seen_at
|
||||
FROM base b
|
||||
CROSS JOIN load_params p
|
||||
WHERE p.load_type = 'forward'
|
||||
AND (
|
||||
(p.offset_date > 0 AND b.message_date >= p.offset_date)
|
||||
OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id > p.offset_id)
|
||||
OR (p.offset_date <= 0 AND p.offset_id > 0 AND b.box_id >= p.offset_id)
|
||||
)
|
||||
ORDER BY b.box_id ASC
|
||||
OFFSET GREATEST((SELECT -add_offset - limit_count FROM load_params), 0)
|
||||
LIMIT (SELECT limit_count FROM load_params)
|
||||
) f
|
||||
),
|
||||
paged AS (
|
||||
SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, hide_edited, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM backward
|
||||
SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, hide_edited, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM backward
|
||||
UNION ALL
|
||||
SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, hide_edited, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM around_forward
|
||||
SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, hide_edited, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM around_forward
|
||||
UNION ALL
|
||||
SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, hide_edited, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM around_backward
|
||||
SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, hide_edited, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM around_backward
|
||||
UNION ALL
|
||||
SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, hide_edited, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM forward
|
||||
SELECT box_id, private_message_id, owner_user_id, peer_type, peer_id, from_user_id, message_date, ttl_period, expires_at, edit_date, hide_edited, outgoing, body, entities_json, silent, noforwards, reply_to_msg_id, reply_to_peer_type, reply_to_peer_id, reply_to_top_id, reply_to_story_id, quote_text, quote_entities_json, quote_offset, reply_external_json, fwd_from_peer_type, fwd_from_peer_id, fwd_from_name, fwd_date, fwd_saved_from_peer_type, fwd_saved_from_peer_id, fwd_saved_from_msg_id, saved_peer_type, saved_peer_id, pts, media_json, media_unread, reaction_unread, pinned, via_bot_id, grouped_id, effect, reply_markup_json, rich_message_json, peer_user_id, peer_access_hash, peer_phone, peer_first_name, peer_last_name, peer_username, peer_country_code, peer_verified, peer_support, peer_is_bot, peer_bot_info_version, peer_premium_until, peer_emoji_status_document_id, peer_emoji_status_until, peer_last_seen_at, from_user_user_id, from_user_access_hash, from_user_phone, from_user_first_name, from_user_last_name, from_user_username, from_user_country_code, from_user_verified, from_user_support, from_user_is_bot, from_user_bot_info_version, from_user_premium_until, from_user_emoji_status_document_id, from_user_emoji_status_until, from_user_last_seen_at FROM forward
|
||||
)
|
||||
SELECT
|
||||
box_id,
|
||||
|
|
@ -2856,6 +2904,7 @@ SELECT
|
|||
quote_text,
|
||||
quote_entities_json,
|
||||
quote_offset,
|
||||
reply_external_json,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -2904,10 +2953,8 @@ SELECT
|
|||
from_user_premium_until,
|
||||
from_user_emoji_status_document_id,
|
||||
from_user_emoji_status_until,
|
||||
from_user_last_seen_at,
|
||||
COALESCE(total.total_count, 0)::int AS total_count
|
||||
from_user_last_seen_at
|
||||
FROM paged
|
||||
CROSS JOIN total
|
||||
ORDER BY box_id DESC
|
||||
`
|
||||
|
||||
|
|
@ -2917,6 +2964,7 @@ type ListMessagesByUserParams struct {
|
|||
OffsetDate int32
|
||||
AddOffset int32
|
||||
LimitCount int32
|
||||
SenderUserID int64
|
||||
HasPeer bool
|
||||
PeerType string
|
||||
PeerID int64
|
||||
|
|
@ -2934,7 +2982,6 @@ type ListMessagesByUserParams struct {
|
|||
SavedPeerType string
|
||||
SavedPeerID int64
|
||||
SavedReactionKeys []string
|
||||
NeedTotalCount bool
|
||||
}
|
||||
|
||||
type ListMessagesByUserRow struct {
|
||||
|
|
@ -2962,6 +3009,7 @@ type ListMessagesByUserRow struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson string
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson string
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -3011,7 +3059,6 @@ type ListMessagesByUserRow struct {
|
|||
FromUserEmojiStatusDocumentID int64
|
||||
FromUserEmojiStatusUntil int64
|
||||
FromUserLastSeenAt int64
|
||||
TotalCount int32
|
||||
}
|
||||
|
||||
func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUserParams) ([]ListMessagesByUserRow, error) {
|
||||
|
|
@ -3021,6 +3068,7 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser
|
|||
arg.OffsetDate,
|
||||
arg.AddOffset,
|
||||
arg.LimitCount,
|
||||
arg.SenderUserID,
|
||||
arg.HasPeer,
|
||||
arg.PeerType,
|
||||
arg.PeerID,
|
||||
|
|
@ -3038,7 +3086,6 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser
|
|||
arg.SavedPeerType,
|
||||
arg.SavedPeerID,
|
||||
arg.SavedReactionKeys,
|
||||
arg.NeedTotalCount,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -3072,6 +3119,7 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser
|
|||
&i.QuoteText,
|
||||
&i.QuoteEntitiesJson,
|
||||
&i.QuoteOffset,
|
||||
&i.ReplyExternalJson,
|
||||
&i.FwdFromPeerType,
|
||||
&i.FwdFromPeerID,
|
||||
&i.FwdFromName,
|
||||
|
|
@ -3121,7 +3169,6 @@ func (q *Queries) ListMessagesByUser(ctx context.Context, arg ListMessagesByUser
|
|||
&i.FromUserEmojiStatusDocumentID,
|
||||
&i.FromUserEmojiStatusUntil,
|
||||
&i.FromUserLastSeenAt,
|
||||
&i.TotalCount,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -3159,6 +3206,7 @@ SELECT
|
|||
quote_text,
|
||||
quote_entities::text AS quote_entities_json,
|
||||
quote_offset,
|
||||
reply_external::text AS reply_external_json,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -3220,6 +3268,7 @@ type ListUnreadReactionMessageBoxesRow struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson string
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson string
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -3280,6 +3329,7 @@ func (q *Queries) ListUnreadReactionMessageBoxes(ctx context.Context, arg ListUn
|
|||
&i.QuoteText,
|
||||
&i.QuoteEntitiesJson,
|
||||
&i.QuoteOffset,
|
||||
&i.ReplyExternalJson,
|
||||
&i.FwdFromPeerType,
|
||||
&i.FwdFromPeerID,
|
||||
&i.FwdFromName,
|
||||
|
|
@ -3337,6 +3387,7 @@ SELECT
|
|||
quote_text,
|
||||
quote_entities::text AS quote_entities_json,
|
||||
quote_offset,
|
||||
reply_external::text AS reply_external_json,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -3397,6 +3448,7 @@ type ListVisibleMessageBoxesByPrivateMessageRow struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson string
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson string
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -3453,6 +3505,7 @@ func (q *Queries) ListVisibleMessageBoxesByPrivateMessage(ctx context.Context, a
|
|||
&i.QuoteText,
|
||||
&i.QuoteEntitiesJson,
|
||||
&i.QuoteOffset,
|
||||
&i.ReplyExternalJson,
|
||||
&i.FwdFromPeerType,
|
||||
&i.FwdFromPeerID,
|
||||
&i.FwdFromName,
|
||||
|
|
@ -3780,6 +3833,7 @@ RETURNING
|
|||
quote_text,
|
||||
quote_entities::text AS quote_entities_json,
|
||||
quote_offset,
|
||||
reply_external::text AS reply_external_json,
|
||||
fwd_from_peer_type,
|
||||
fwd_from_peer_id,
|
||||
fwd_from_name,
|
||||
|
|
@ -3841,6 +3895,7 @@ type UpdateMessageBoxEditRow struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson string
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson string
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -3903,6 +3958,7 @@ func (q *Queries) UpdateMessageBoxEdit(ctx context.Context, arg UpdateMessageBox
|
|||
&i.QuoteText,
|
||||
&i.QuoteEntitiesJson,
|
||||
&i.QuoteOffset,
|
||||
&i.ReplyExternalJson,
|
||||
&i.FwdFromPeerType,
|
||||
&i.FwdFromPeerID,
|
||||
&i.FwdFromName,
|
||||
|
|
|
|||
|
|
@ -147,6 +147,11 @@ type AccountSetting struct {
|
|||
SensitiveContentEnabled bool
|
||||
ContactSignupSilent bool
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
DisallowUnlimitedStargifts bool
|
||||
DisallowLimitedStargifts bool
|
||||
DisallowUniqueStargifts bool
|
||||
DisallowPremiumGifts bool
|
||||
DisallowStargiftsFromChannels bool
|
||||
}
|
||||
|
||||
type AdminAuditLog struct {
|
||||
|
|
@ -183,6 +188,18 @@ type AdminCommand struct {
|
|||
CompletedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type AdminConsoleUser struct {
|
||||
ID int64
|
||||
Username string
|
||||
PasswordHash string
|
||||
Permissions []string
|
||||
Enabled bool
|
||||
TokenEpoch int32
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
LastLoginAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type AiComposeTone struct {
|
||||
ID int64
|
||||
AccessHash int64
|
||||
|
|
@ -505,6 +522,39 @@ type BotVerifierSetting struct {
|
|||
Version int64
|
||||
}
|
||||
|
||||
type Broadcast struct {
|
||||
ID int64
|
||||
Message string
|
||||
TargetMode string
|
||||
TargetCount int64
|
||||
CreatedBy string
|
||||
CreatedAt pgtype.Timestamptz
|
||||
Entities []byte
|
||||
SnapshotMaxUserID int64
|
||||
EnumerationCursorUserID int64
|
||||
EnumerationDone bool
|
||||
MaterializedCount int64
|
||||
SentCount int64
|
||||
FailedCount int64
|
||||
}
|
||||
|
||||
type BroadcastRecipient struct {
|
||||
ID int64
|
||||
BroadcastID int64
|
||||
UserID int64
|
||||
Status string
|
||||
Attempts int32
|
||||
LastError string
|
||||
SentAt pgtype.Timestamptz
|
||||
NextAttemptAt pgtype.Timestamptz
|
||||
LeaseToken string
|
||||
LeaseUntil pgtype.Timestamptz
|
||||
PrivateMessageID int64
|
||||
MessageBoxID int32
|
||||
Pts int32
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type BusinessAutomationDelivery struct {
|
||||
OwnerUserID int64
|
||||
PeerUserID int64
|
||||
|
|
@ -1324,6 +1374,19 @@ type FileBlob struct {
|
|||
CreatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type GifCatalog struct {
|
||||
ID int64
|
||||
Title string
|
||||
DocumentID int64
|
||||
Enabled bool
|
||||
SortOrder int32
|
||||
CreatedBy string
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
SourceFilename string
|
||||
Category string
|
||||
}
|
||||
|
||||
type GroupCall struct {
|
||||
CallID int64
|
||||
AccessHash int64
|
||||
|
|
@ -1499,6 +1562,7 @@ type MessageBox struct {
|
|||
ReplyToStoryID int32
|
||||
Effect int64
|
||||
HideEdited bool
|
||||
ReplyExternal []byte
|
||||
}
|
||||
|
||||
type MessageBoxMedium struct {
|
||||
|
|
@ -1796,6 +1860,7 @@ type PrivateMessage struct {
|
|||
SenderDeletePtsCount int32
|
||||
SenderDeleteDate int32
|
||||
SenderDeleteMessageIds []byte
|
||||
ReplyExternal []byte
|
||||
}
|
||||
|
||||
type PrivateMessageReaction struct {
|
||||
|
|
@ -2642,6 +2707,15 @@ type SuggestedPostApproval struct {
|
|||
FinalServiceMessageID int32
|
||||
CreatedAt int32
|
||||
UpdatedAt int32
|
||||
LifecycleAttempts int32
|
||||
NextAttemptAt int32
|
||||
LastLifecycleError string
|
||||
}
|
||||
|
||||
type SuggestedPostLifecycleWakeup struct {
|
||||
MonoforumID int64
|
||||
SuggestionMessageID int32
|
||||
CreatedAt int32
|
||||
}
|
||||
|
||||
type TelegramLoginCode struct {
|
||||
|
|
@ -3125,3 +3199,43 @@ type WebviewRequestedButton struct {
|
|||
UsernameRequested bool
|
||||
PhotoRequested bool
|
||||
}
|
||||
|
||||
type WelcomeMessage struct {
|
||||
ChannelID int64
|
||||
ID int32
|
||||
CreatorUserID int64
|
||||
Date int32
|
||||
EditDate int32
|
||||
RandomID int64
|
||||
Content []byte
|
||||
CreateFingerprint []byte
|
||||
Version int64
|
||||
CreatedAt pgtype.Timestamptz
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type WelcomeMessageDelivery struct {
|
||||
ID int64
|
||||
JoinEventID int64
|
||||
ChannelID int64
|
||||
TargetUserID int64
|
||||
TemplateID int32
|
||||
EphemeralID *int32
|
||||
JoinedAt int32
|
||||
Content []byte
|
||||
AttemptCount int32
|
||||
NextAttemptAt pgtype.Timestamptz
|
||||
LeaseOwner *string
|
||||
LeaseExpiresAt pgtype.Timestamptz
|
||||
DeliveredAt pgtype.Timestamptz
|
||||
LastError string
|
||||
CreatedAt pgtype.Timestamptz
|
||||
ExpiresAt pgtype.Timestamptz
|
||||
}
|
||||
|
||||
type WelcomeMessagePeer struct {
|
||||
ChannelID int64
|
||||
NextID int32
|
||||
Revision int64
|
||||
UpdatedAt pgtype.Timestamptz
|
||||
}
|
||||
|
|
|
|||
|
|
@ -274,6 +274,7 @@ SELECT
|
|||
m.quote_text,
|
||||
m.quote_entities::text AS quote_entities_json,
|
||||
m.quote_offset,
|
||||
m.reply_external::text AS reply_external_json,
|
||||
m.fwd_from_peer_type,
|
||||
m.fwd_from_peer_id,
|
||||
m.fwd_from_name,
|
||||
|
|
@ -332,6 +333,7 @@ type ListPinnedSavedDialogTopsRow struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson string
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson string
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -390,6 +392,7 @@ func (q *Queries) ListPinnedSavedDialogTops(ctx context.Context, ownerUserID int
|
|||
&i.QuoteText,
|
||||
&i.QuoteEntitiesJson,
|
||||
&i.QuoteOffset,
|
||||
&i.ReplyExternalJson,
|
||||
&i.FwdFromPeerType,
|
||||
&i.FwdFromPeerID,
|
||||
&i.FwdFromName,
|
||||
|
|
@ -460,6 +463,7 @@ SELECT
|
|||
m.quote_text,
|
||||
m.quote_entities::text AS quote_entities_json,
|
||||
m.quote_offset,
|
||||
m.reply_external::text AS reply_external_json,
|
||||
m.fwd_from_peer_type,
|
||||
m.fwd_from_peer_id,
|
||||
m.fwd_from_name,
|
||||
|
|
@ -528,6 +532,7 @@ type ListSavedDialogTopsRow struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson string
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson string
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -595,6 +600,7 @@ func (q *Queries) ListSavedDialogTops(ctx context.Context, arg ListSavedDialogTo
|
|||
&i.QuoteText,
|
||||
&i.QuoteEntitiesJson,
|
||||
&i.QuoteOffset,
|
||||
&i.ReplyExternalJson,
|
||||
&i.FwdFromPeerType,
|
||||
&i.FwdFromPeerID,
|
||||
&i.FwdFromName,
|
||||
|
|
@ -673,6 +679,7 @@ SELECT
|
|||
m.quote_text,
|
||||
m.quote_entities::text AS quote_entities_json,
|
||||
m.quote_offset,
|
||||
m.reply_external::text AS reply_external_json,
|
||||
m.fwd_from_peer_type,
|
||||
m.fwd_from_peer_id,
|
||||
m.fwd_from_name,
|
||||
|
|
@ -737,6 +744,7 @@ type ListSavedDialogTopsByPeersRow struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson string
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson string
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -795,6 +803,7 @@ func (q *Queries) ListSavedDialogTopsByPeers(ctx context.Context, arg ListSavedD
|
|||
&i.QuoteText,
|
||||
&i.QuoteEntitiesJson,
|
||||
&i.QuoteOffset,
|
||||
&i.ReplyExternalJson,
|
||||
&i.FwdFromPeerType,
|
||||
&i.FwdFromPeerID,
|
||||
&i.FwdFromName,
|
||||
|
|
|
|||
|
|
@ -174,6 +174,7 @@ SELECT
|
|||
COALESCE(m.quote_text, '')::text AS quote_text,
|
||||
COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json,
|
||||
COALESCE(m.quote_offset, 0)::int AS quote_offset,
|
||||
COALESCE(m.reply_external, '{}'::jsonb)::text AS reply_external_json,
|
||||
COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type,
|
||||
COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id,
|
||||
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
|
||||
|
|
@ -313,6 +314,7 @@ type BatchListDispatchEventsRow struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson string
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson string
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -450,6 +452,7 @@ func (q *Queries) BatchListDispatchEvents(ctx context.Context, arg BatchListDisp
|
|||
&i.QuoteText,
|
||||
&i.QuoteEntitiesJson,
|
||||
&i.QuoteOffset,
|
||||
&i.ReplyExternalJson,
|
||||
&i.FwdFromPeerType,
|
||||
&i.FwdFromPeerID,
|
||||
&i.FwdFromName,
|
||||
|
|
@ -833,6 +836,7 @@ SELECT
|
|||
COALESCE(m.quote_text, '')::text AS quote_text,
|
||||
COALESCE(m.quote_entities::text, '[]')::text AS quote_entities_json,
|
||||
COALESCE(m.quote_offset, 0)::int AS quote_offset,
|
||||
COALESCE(m.reply_external, '{}'::jsonb)::text AS reply_external_json,
|
||||
COALESCE(m.fwd_from_peer_type, '')::text AS fwd_from_peer_type,
|
||||
COALESCE(m.fwd_from_peer_id, 0)::bigint AS fwd_from_peer_id,
|
||||
COALESCE(m.fwd_from_name, '')::text AS fwd_from_name,
|
||||
|
|
@ -975,6 +979,7 @@ type ListUserUpdateEventsAfterRow struct {
|
|||
QuoteText string
|
||||
QuoteEntitiesJson string
|
||||
QuoteOffset int32
|
||||
ReplyExternalJson string
|
||||
FwdFromPeerType string
|
||||
FwdFromPeerID int64
|
||||
FwdFromName string
|
||||
|
|
@ -1110,6 +1115,7 @@ func (q *Queries) ListUserUpdateEventsAfter(ctx context.Context, arg ListUserUpd
|
|||
&i.QuoteText,
|
||||
&i.QuoteEntitiesJson,
|
||||
&i.QuoteOffset,
|
||||
&i.ReplyExternalJson,
|
||||
&i.FwdFromPeerType,
|
||||
&i.FwdFromPeerID,
|
||||
&i.FwdFromName,
|
||||
|
|
|
|||
|
|
@ -352,6 +352,7 @@ func (s *UpdateEventStore) ListAfter(ctx context.Context, userID int64, pts, lim
|
|||
row.QuoteText,
|
||||
row.QuoteEntitiesJson,
|
||||
row.QuoteOffset,
|
||||
row.ReplyExternalJson,
|
||||
row.FwdFromPeerType,
|
||||
row.FwdFromPeerID,
|
||||
row.FwdFromName,
|
||||
|
|
@ -560,6 +561,7 @@ func (s *UpdateEventStore) BatchByCursor(ctx context.Context, cursors []store.Ev
|
|||
row.QuoteText,
|
||||
row.QuoteEntitiesJson,
|
||||
row.QuoteOffset,
|
||||
row.ReplyExternalJson,
|
||||
row.FwdFromPeerType,
|
||||
row.FwdFromPeerID,
|
||||
row.FwdFromName,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue