feat: use postgres
This commit is contained in:
@@ -0,0 +1,306 @@
|
||||
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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user