Files
galaxy-game/mail/internal/adapters/postgres/mailstore/operator.go
T
2026-04-26 20:34:39 +02:00

307 lines
11 KiB
Go

package mailstore
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
pgtable "galaxy/mail/internal/adapters/postgres/jet/mail/table"
"galaxy/mail/internal/domain/attempt"
"galaxy/mail/internal/domain/common"
deliverydomain "galaxy/mail/internal/domain/delivery"
"galaxy/mail/internal/domain/idempotency"
"galaxy/mail/internal/service/acceptgenericdelivery"
"galaxy/mail/internal/service/listdeliveries"
"galaxy/mail/internal/service/resenddelivery"
pg "github.com/go-jet/jet/v2/postgres"
)
// resendIdempotencyExpiry stores the synthetic idempotency_expires_at value
// applied to resend deliveries. Resend rows do not carry a caller-supplied
// idempotency reservation; the fingerprint is stored as the empty string and
// the loadIdempotencyByScope helper treats those rows as non-idempotent —
// the expiry is therefore irrelevant in practice but must satisfy the
// `NOT NULL > created_at` invariant used by the deliveries column.
const resendIdempotencyExpiry = 100 * 365 * 24 * time.Hour
// maxIdempotencyExpiry is the fallback expiry duration used when no caller-
// supplied idempotency.Record reservation accompanies the write.
var maxIdempotencyExpiry = resendIdempotencyExpiry
// GetIdempotency loads the idempotency reservation for one (source, key)
// scope. It is shared by the auth-acceptance and generic-acceptance flows.
func (store *Store) GetIdempotency(ctx context.Context, source deliverydomain.Source, key common.IdempotencyKey) (idempotency.Record, bool, error) {
if store == nil {
return idempotency.Record{}, false, errors.New("get idempotency: nil store")
}
operationCtx, cancel, err := store.operationContext(ctx, "get idempotency")
if err != nil {
return idempotency.Record{}, false, err
}
defer cancel()
record, ok, err := loadIdempotencyByScope(operationCtx, store.db, source, key)
if err != nil {
return idempotency.Record{}, false, fmt.Errorf("get idempotency: %w", err)
}
return record, ok, nil
}
// GetDeadLetter loads the dead_letters row for deliveryID when one exists.
func (store *Store) GetDeadLetter(ctx context.Context, deliveryID common.DeliveryID) (deliverydomain.DeadLetterEntry, bool, error) {
if store == nil {
return deliverydomain.DeadLetterEntry{}, false, errors.New("get dead-letter: nil store")
}
operationCtx, cancel, err := store.operationContext(ctx, "get dead-letter")
if err != nil {
return deliverydomain.DeadLetterEntry{}, false, err
}
defer cancel()
entry, ok, err := loadDeadLetter(operationCtx, store.db, deliveryID)
if err != nil {
return deliverydomain.DeadLetterEntry{}, false, fmt.Errorf("get dead-letter: %w", err)
}
return entry, ok, nil
}
// GetDeliveryPayload returns the raw attachment payload bundle for deliveryID
// when one exists.
func (store *Store) GetDeliveryPayload(ctx context.Context, deliveryID common.DeliveryID) (acceptgenericdelivery.DeliveryPayload, bool, error) {
if store == nil {
return acceptgenericdelivery.DeliveryPayload{}, false, errors.New("get delivery payload: nil store")
}
operationCtx, cancel, err := store.operationContext(ctx, "get delivery payload")
if err != nil {
return acceptgenericdelivery.DeliveryPayload{}, false, err
}
defer cancel()
encoded, ok, err := loadDeliveryPayload(operationCtx, store.db, deliveryID)
if err != nil {
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("get delivery payload: %w", err)
}
if !ok {
return acceptgenericdelivery.DeliveryPayload{}, false, nil
}
payload, err := unmarshalDeliveryPayload(deliveryID, encoded)
if err != nil {
return acceptgenericdelivery.DeliveryPayload{}, false, fmt.Errorf("get delivery payload: %w", err)
}
return payload, true, nil
}
// ListAttempts loads exactly expectedCount attempts in attempt_no ASC order
// for deliveryID. A gap in the stored sequence surfaces as an error so
// operator reads fail closed on durable-state corruption.
func (store *Store) ListAttempts(ctx context.Context, deliveryID common.DeliveryID, expectedCount int) ([]attempt.Attempt, error) {
if store == nil {
return nil, errors.New("list attempts: nil store")
}
if expectedCount < 0 {
return nil, errors.New("list attempts: negative expected count")
}
if expectedCount == 0 {
return []attempt.Attempt{}, nil
}
if err := deliveryID.Validate(); err != nil {
return nil, fmt.Errorf("list attempts: %w", err)
}
operationCtx, cancel, err := store.operationContext(ctx, "list attempts")
if err != nil {
return nil, err
}
defer cancel()
out, err := loadAttempts(operationCtx, store.db, deliveryID, expectedCount)
if err != nil {
return nil, fmt.Errorf("list attempts: %w", err)
}
return out, nil
}
// List returns one filtered ordered page of delivery records keyed by
// (created_at DESC, delivery_id DESC). Filters compose into SQL WHERE
// clauses — every supported filter is index-friendly.
func (store *Store) List(ctx context.Context, input listdeliveries.Input) (listdeliveries.Result, error) {
if store == nil {
return listdeliveries.Result{}, errors.New("list deliveries: nil store")
}
if err := input.Validate(); err != nil {
return listdeliveries.Result{}, fmt.Errorf("list deliveries: %w", err)
}
limit := input.Limit
if limit <= 0 {
limit = listdeliveries.DefaultLimit
}
operationCtx, cancel, err := store.operationContext(ctx, "list deliveries")
if err != nil {
return listdeliveries.Result{}, err
}
defer cancel()
if input.Cursor != nil {
cursorStmt := pg.SELECT(pgtable.Deliveries.CreatedAt).
FROM(pgtable.Deliveries).
WHERE(pgtable.Deliveries.DeliveryID.EQ(pg.String(input.Cursor.DeliveryID.String())))
cursorQuery, cursorArgs := cursorStmt.Sql()
row := store.db.QueryRowContext(operationCtx, cursorQuery, cursorArgs...)
var createdAt sql.NullTime
if err := row.Scan(&createdAt); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return listdeliveries.Result{}, listdeliveries.ErrInvalidCursor
}
return listdeliveries.Result{}, fmt.Errorf("list deliveries: validate cursor: %w", err)
}
if !createdAt.Valid || !createdAt.Time.UTC().Equal(input.Cursor.CreatedAt.UTC()) {
return listdeliveries.Result{}, listdeliveries.ErrInvalidCursor
}
}
conditions := make([]pg.BoolExpression, 0, 8)
if input.Cursor != nil {
cursorCreatedAt := pg.TimestampzT(input.Cursor.CreatedAt.UTC())
cursorID := pg.String(input.Cursor.DeliveryID.String())
// (created_at, delivery_id) < (cursorCreatedAt, cursorID) expressed as
// the equivalent OR/AND expansion since jet has no row-comparison
// builder.
conditions = append(conditions, pg.OR(
pgtable.Deliveries.CreatedAt.LT(cursorCreatedAt),
pg.AND(
pgtable.Deliveries.CreatedAt.EQ(cursorCreatedAt),
pgtable.Deliveries.DeliveryID.LT(cursorID),
),
))
}
if input.Filters.Status != "" {
conditions = append(conditions, pgtable.Deliveries.Status.EQ(pg.String(string(input.Filters.Status))))
}
if input.Filters.Source != "" {
conditions = append(conditions, pgtable.Deliveries.Source.EQ(pg.String(string(input.Filters.Source))))
}
if !input.Filters.TemplateID.IsZero() {
conditions = append(conditions, pgtable.Deliveries.TemplateID.EQ(pg.String(input.Filters.TemplateID.String())))
}
if !input.Filters.IdempotencyKey.IsZero() {
conditions = append(conditions, pgtable.Deliveries.IdempotencyKey.EQ(pg.String(input.Filters.IdempotencyKey.String())))
}
if input.Filters.FromCreatedAt != nil {
conditions = append(conditions, pgtable.Deliveries.CreatedAt.GT_EQ(pg.TimestampzT(input.Filters.FromCreatedAt.UTC())))
}
if input.Filters.ToCreatedAt != nil {
conditions = append(conditions, pgtable.Deliveries.CreatedAt.LT_EQ(pg.TimestampzT(input.Filters.ToCreatedAt.UTC())))
}
if !input.Filters.Recipient.IsZero() {
recipientSub := pg.SELECT(pgtable.DeliveryRecipients.DeliveryID).
FROM(pgtable.DeliveryRecipients).
WHERE(pg.AND(
pgtable.DeliveryRecipients.Kind.NOT_EQ(pg.String(recipientKindReplyTo)),
pg.LOWER(pgtable.DeliveryRecipients.Email).EQ(pg.LOWER(pg.String(input.Filters.Recipient.String()))),
))
conditions = append(conditions, pgtable.Deliveries.DeliveryID.IN(recipientSub))
}
stmt := pg.SELECT(deliverySelectColumns).
FROM(pgtable.Deliveries)
if len(conditions) > 0 {
stmt = stmt.WHERE(pg.AND(conditions...))
}
stmt = stmt.
ORDER_BY(pgtable.Deliveries.CreatedAt.DESC(), pgtable.Deliveries.DeliveryID.DESC()).
LIMIT(int64(limit + 1))
query, args := stmt.Sql()
rows, err := store.db.QueryContext(operationCtx, query, args...)
if err != nil {
return listdeliveries.Result{}, fmt.Errorf("list deliveries: %w", err)
}
defer rows.Close()
items := make([]deliverydomain.Delivery, 0, limit+1)
for rows.Next() {
record, _, err := scanDelivery(rows)
if err != nil {
return listdeliveries.Result{}, fmt.Errorf("list deliveries: scan: %w", err)
}
envelope, err := loadEnvelope(operationCtx, store.db, record.DeliveryID)
if err != nil {
return listdeliveries.Result{}, fmt.Errorf("list deliveries: load envelope: %w", err)
}
record.Envelope = envelope
items = append(items, record)
}
if err := rows.Err(); err != nil {
return listdeliveries.Result{}, fmt.Errorf("list deliveries: %w", err)
}
result := listdeliveries.Result{}
if len(items) > limit {
next := listdeliveries.Cursor{
CreatedAt: items[limit-1].CreatedAt.UTC(),
DeliveryID: items[limit-1].DeliveryID,
}
result.NextCursor = &next
items = items[:limit]
}
result.Items = items
return result, nil
}
// CreateResend writes the cloned delivery, its first attempt, and the
// optional cloned payload bundle inside one transaction. Resend deliveries
// share the (source, idempotency_key) UNIQUE constraint, so a duplicate clone
// surfaces as a generic acceptance conflict — but the resend service
// generates fresh idempotency keys, so a conflict here always indicates a
// caller bug rather than user-replay.
func (store *Store) CreateResend(ctx context.Context, input resenddelivery.CreateResendInput) error {
if store == nil {
return errors.New("create resend: nil store")
}
if ctx == nil {
return errors.New("create resend: nil context")
}
if err := input.Validate(); err != nil {
return fmt.Errorf("create resend: %w", err)
}
return store.withTx(ctx, "create resend", func(ctx context.Context, tx *sql.Tx) error {
// Use the delivery's own UpdatedAt as a deterministic finite expiry —
// the resend has no caller-supplied idempotency.Record reservation.
fallbackExpiresAt := input.Delivery.CreatedAt.Add(maxIdempotencyExpiry)
first := input.FirstAttempt
if err := insertDelivery(ctx, tx, input.Delivery, idempotency.Record{}, fallbackExpiresAt, &first); err != nil {
if isUniqueViolation(err) {
return fmt.Errorf("create resend: %w", err)
}
return fmt.Errorf("create resend: insert delivery: %w", err)
}
if err := insertAttempt(ctx, tx, input.FirstAttempt); err != nil {
return fmt.Errorf("create resend: insert first attempt: %w", err)
}
if input.DeliveryPayload != nil {
payload, err := marshalDeliveryPayload(*input.DeliveryPayload)
if err != nil {
return fmt.Errorf("create resend: %w", err)
}
payloadStmt := pgtable.DeliveryPayloads.INSERT(
pgtable.DeliveryPayloads.DeliveryID,
pgtable.DeliveryPayloads.Payload,
).VALUES(
input.Delivery.DeliveryID.String(),
payload,
)
payloadQuery, payloadArgs := payloadStmt.Sql()
if _, err := tx.ExecContext(ctx, payloadQuery, payloadArgs...); err != nil {
return fmt.Errorf("create resend: insert delivery payload: %w", err)
}
}
return nil
})
}