pipework 0.254.0 → 0.255.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -9,6 +9,7 @@ import { define } from '../../data/domain/index.js'
9
9
  import { field } from '../../data/domain/field.js'
10
10
  import { schema } from '../../data/schema/namespace.js'
11
11
  import { index, unique } from '../../data/db/idx/index.js'
12
+ import { sql } from '../../data/db/sql.js'
12
13
 
13
14
  export const EMAIL_TABLE_NAMES: ReadonlySet<string> = new Set([
14
15
  'pipework_email_messages', 'pipework_email_suppressions', 'pipework_email_inbound', 'pipework_email_unsubscribes',
@@ -71,6 +72,8 @@ export const emailInbound = define('pipework_email_inbound', {
71
72
  rawLocation: field.jsonb(schema.check.json()),
72
73
  rawExpiresAt: field.timestamp(),
73
74
  rawDeletedAt: field.timestamp().nullable(),
75
+ // Set when `sweep` nulls the address, subject, recipients and Message-ID after `metadataRetentionDays`.
76
+ metadataScrubbedAt: field.timestamp().nullable(),
74
77
  status: field.enum(INBOUND_STATUSES).default('received'),
75
78
  error: field.text().nullable(),
76
79
  messageIdHeader: field.text().nullable(),
@@ -92,5 +95,6 @@ export const emailInbound = define('pipework_email_inbound', {
92
95
  bySource: unique('pipework_email_inbound_source_id_key').on(t['sourceId']),
93
96
  byMessageId: index('idx_pipework_email_inbound_message_id').on(t['messageIdHeader']),
94
97
  byExpiry: index('idx_pipework_email_inbound_raw_expires').on(t['rawExpiresAt']),
98
+ unscrubbed: index('idx_pipework_email_inbound_unscrubbed').on(t['receivedAt']).where(sql.raw('metadata_scrubbed_at IS NULL')),
95
99
  }),
96
100
  })
@@ -94,7 +94,7 @@ const processEvent = __flowStep({ ref: 'pipework/email:notification' }, async (e
94
94
  } else {
95
95
  getBaseLogger().info({ event: 'email bounce not suppressed', address, bounceType: r.bounceType }, 'transient bounce: address kept')
96
96
  }
97
- await auditEmail(r.kind, messageId, { address, bounceType: r.bounceType, suppressed: permanent, messageTraceId })
97
+ await auditEmail(r.kind, messageId, { bounceType: r.bounceType, suppressed: permanent, messageTraceId })
98
98
  }
99
99
  })
100
100
 
@@ -13,6 +13,9 @@ const LEASE_MS = 30_000
13
13
  const BATCH = 50
14
14
  const MAX_BATCHES = 20
15
15
  const MAX_BACKOFF_MS = 3_600_000
16
+ const SWEEP_BATCH = 500
17
+ const DEFAULT_SWEEP_LIMIT = 10_000
18
+ const SWEEP_EVERY_MS = 3_600_000
16
19
 
17
20
  export interface OutboxTotals {
18
21
  delivered: number
@@ -61,6 +64,27 @@ export interface OutboxOptions<D> {
61
64
  readonly onGivenUp: (row: Record<string, unknown>, attempt: number, error: string) => Promise<void>
62
65
  }
63
66
 
67
+ /** Retention for an outbox's rows, in days. Unset: kept. */
68
+ export interface OutboxRetention {
69
+ /** Delivered rows (and their payloads) older than this are deleted. */
70
+ readonly deliveredRetentionDays: number
71
+ /** Given-up rows (`failed_at` set, never delivered) older than this are deleted. Unset: kept. */
72
+ readonly failedRetentionDays?: number
73
+ }
74
+
75
+ /** Refuses a retention that is not whole days of zero or more, naming `who` and the option. */
76
+ export function checkRetention(who: string, config: { readonly deliveredRetentionDays?: number; readonly failedRetentionDays?: number }): OutboxRetention | undefined {
77
+ for (const name of ['deliveredRetentionDays', 'failedRetentionDays'] as const) {
78
+ const v = config[name]
79
+ if (v !== undefined && !(Number.isInteger(v) && v >= 0)) throw new Error(`[pipework] ${who}: ${name} must be an integer of zero or more, got ${String(v)}.`)
80
+ }
81
+ if (config.failedRetentionDays !== undefined && config.deliveredRetentionDays === undefined) {
82
+ throw new Error(`[pipework] ${who}: failedRetentionDays needs deliveredRetentionDays too.`)
83
+ }
84
+ if (config.deliveredRetentionDays === undefined) return undefined
85
+ return { deliveredRetentionDays: config.deliveredRetentionDays, ...(config.failedRetentionDays !== undefined ? { failedRetentionDays: config.failedRetentionDays } : {}) }
86
+ }
87
+
64
88
  export interface Outbox {
65
89
  /** Delivers what is due now, up to 20 batches; rows that fail wait for the next call. */
66
90
  drain(): Promise<OutboxTotals>
@@ -70,6 +94,26 @@ export interface Outbox {
70
94
  * `prev_attempts`, the attempts it had when given up.
71
95
  */
72
96
  requeue(options: { sink?: string; failedSince?: Date }): Promise<Record<string, unknown>[]>
97
+ /**
98
+ * Deletes delivered rows whose `delivered_at` is older than `deliveredRetentionDays` and, with `failedRetentionDays`,
99
+ * given-up rows whose `failed_at` is older than that. A row still queued or being retried is never deleted.
100
+ * Deletes in batches of 500, at most `limit` rows per call (default 10 000).
101
+ */
102
+ sweep(options: OutboxRetention & { limit?: number }): Promise<{ deleted: number }>
103
+ }
104
+
105
+ /** A function that runs `outbox.sweep` with `retention` at most once an hour per process, and never throws; `onError` gets a failure. No retention: does nothing. */
106
+ export function hourlySweep(outbox: Outbox, retention: OutboxRetention | undefined, onError: (err: unknown) => void): () => Promise<void> {
107
+ let last = 0
108
+ return async () => {
109
+ if (retention === undefined || Date.now() - last < SWEEP_EVERY_MS) return
110
+ last = Date.now()
111
+ try {
112
+ await outbox.sweep(retention)
113
+ } catch (err) {
114
+ onError(err)
115
+ }
116
+ }
73
117
  }
74
118
 
75
119
  function withTimeout(promise: Promise<void>, ms: number): Promise<void> {
@@ -136,7 +180,33 @@ export function createOutbox<D>(options: OutboxOptions<D>): Outbox {
136
180
  }))
137
181
  }
138
182
 
183
+ async function deleteBatch(predicate: ReturnType<typeof sql>, size: number): Promise<number> {
184
+ const rows = await db().execute(sql`
185
+ DELETE FROM ${table} WHERE id IN (SELECT id FROM ${table} WHERE ${predicate} LIMIT ${size}) RETURNING id`)
186
+ return rows.length
187
+ }
188
+
139
189
  return {
190
+ async sweep(opts) {
191
+ const days = [['deliveredRetentionDays', opts.deliveredRetentionDays], ['failedRetentionDays', opts.failedRetentionDays], ['limit', opts.limit]] as const
192
+ for (const [name, v] of days) {
193
+ if (v !== undefined && !(Number.isInteger(v) && v >= (name === 'limit' ? 1 : 0))) {
194
+ throw new ValidationError({ [name]: ['must be a whole number' + (name === 'limit' ? ' of one or more' : ' of zero or more')] }, `Outbox sweep ${name} is invalid.`)
195
+ }
196
+ }
197
+ const delivered = sql`delivered_at IS NOT NULL AND delivered_at < clock_timestamp() - ${opts.deliveredRetentionDays} * interval '1 day'`
198
+ const failed = opts.failedRetentionDays === undefined ? null
199
+ : sql`delivered_at IS NULL AND failed_at IS NOT NULL AND failed_at < clock_timestamp() - ${opts.failedRetentionDays} * interval '1 day'`
200
+ const predicate = failed === null ? delivered : sql`((${delivered}) OR (${failed}))`
201
+ const limit = opts.limit ?? DEFAULT_SWEEP_LIMIT
202
+ let deleted = 0
203
+ while (deleted < limit) {
204
+ const n = await deleteBatch(predicate, Math.min(SWEEP_BATCH, limit - deleted))
205
+ deleted += n
206
+ if (n === 0) break
207
+ }
208
+ return { deleted }
209
+ },
140
210
  async requeue(opts) {
141
211
  if (opts.failedSince !== undefined && !(opts.failedSince instanceof Date && !Number.isNaN(opts.failedSince.getTime()))) {
142
212
  throw new ValidationError({ failedSince: ['must be a valid Date'] }, 'Outbox failedSince must be a Date.')
@@ -17,7 +17,7 @@ import { recordBeat, beatsEnabled } from '../alerts/beat.js'
17
17
  import type { Mail } from '../email/mail.js'
18
18
  import { sanitizeHtml, htmlToText } from '../email/sanitize.js'
19
19
  import type { ReceivedEmail } from '../email/inbound.js'
20
- import { createOutbox, type Sink, type SinkDelivery } from '../outbox/core.js'
20
+ import { checkRetention, createOutbox, hourlySweep, type Sink, type SinkDelivery } from '../outbox/core.js'
21
21
  import { TICKET_STATES } from './schema.js'
22
22
  import { describeError } from '../../core/logging/error-chain.js'
23
23
 
@@ -99,6 +99,14 @@ export interface SupportConfig {
99
99
  readonly maxAttempts?: number
100
100
  /** First retry delay, doubling per attempt up to one hour; default 5000. */
101
101
  readonly backoffMs?: number
102
+ /**
103
+ * Days a delivered event row, with its payload (the ticket subject), is kept before the sweep deletes it. Unset: kept.
104
+ * `tick` (so `support.job`) runs the sweep at most once an hour per process; `sweep()` runs it on demand.
105
+ * The ticket audit rows carry ids only; they expire with the trace partitions (`auditRetentionDays`).
106
+ */
107
+ readonly deliveredRetentionDays?: number
108
+ /** Days an event row that was given up on (never delivered) is kept. Needs `deliveredRetentionDays`. Unset: kept. */
109
+ readonly failedRetentionDays?: number
102
110
  }
103
111
 
104
112
  export interface Ticket {
@@ -256,6 +264,8 @@ export interface Support extends WithFor<TicketApi> {
256
264
  readonly job: Handler<Record<string, unknown>, unknown>
257
265
  /** What one run of `job` does; callable from a test. Needs a Flow. */
258
266
  tick(): Promise<{ delivered: number; retried: number; failed: number }>
267
+ /** Deletes event rows past `deliveredRetentionDays` / `failedRetentionDays` now, up to `limit` (default 10 000); `deleted: 0` when neither is set. Needs a Flow. */
268
+ sweep(options?: { limit?: number }): Promise<{ deleted: number }>
259
269
  /**
260
270
  * Puts the event deliveries that were given up (and never delivered) back on the queue, for every tenant, optionally
261
271
  * only those of `sink` and/or that failed at or after `failedSince`; the next `tick` sends them. Delivery ids stay the
@@ -358,6 +368,7 @@ export function createSupport(config: SupportConfig): Support {
358
368
  if ((joinThread as string) !== 'contact' && (joinThread as string) !== 'conversation') {
359
369
  throw new Error(`[pipework] support.create: joinThread must be 'contact' or 'conversation', got ${JSON.stringify(joinThread)}.`)
360
370
  }
371
+ const retention = checkRetention('support.create', config)
361
372
  const jobType = config.jobType ?? 'pipework.support.events'
362
373
  const replyTemplate = config.replyTemplate ?? 'pipework.support.reply'
363
374
  const distinctApprover = config.distinctApprover ?? true
@@ -545,7 +556,7 @@ export function createSupport(config: SupportConfig): Support {
545
556
  RETURNING *`)
546
557
  ticket = toTicket(rows[0]!)
547
558
  created = true
548
- await audit(flow, tx, tenantId, 'create', ticket.id, { subject: ticket.subject, contact: ticket.contactEmail, channel: m.channel, userId: ticket.userId })
559
+ await audit(flow, tx, tenantId, 'create', ticket.id, { channel: m.channel, userId: ticket.userId })
549
560
  } else {
550
561
  await discardLive(flow, tx, ticket, 'customer-replied')
551
562
  if (ticket.state !== 'open') ticket = await moveTo(flow, tx, ticket, 'open', true)
@@ -802,7 +813,15 @@ export function createSupport(config: SupportConfig): Support {
802
813
  await audit(flow, null, payload.tenantId, verb, row['ticket_id'] as string, { sink: row['sink'], kind: row['kind'], ...extra })
803
814
  }
804
815
 
805
- const drainStep = __flowStep({ ref: 'pipework/support:tick' }, () => outbox.drain())
816
+ const sweepIfDue = hourlySweep(outbox, retention, (err) => {
817
+ getBaseLogger().error({ err }, 'support event sweep failed')
818
+ void recordFault('support-event-sweep', faultMessage(err), {})
819
+ })
820
+ const drainStep = __flowStep({ ref: 'pipework/support:tick' }, async () => {
821
+ const totals = await outbox.drain()
822
+ await sweepIfDue()
823
+ return totals
824
+ })
806
825
  // A beat for `alerts.source.loopStalled`, written only where the blueprint has `alerts`.
807
826
  const beat = async (outcome: { ok: boolean; error?: string }): Promise<void> => {
808
827
  const ctx = getContext()
@@ -855,6 +874,10 @@ export function createSupport(config: SupportConfig): Support {
855
874
  },
856
875
  job: job as unknown as Handler<Record<string, unknown>, unknown>,
857
876
  tick: () => tickStep(),
877
+ async sweep(options = {}) {
878
+ requireContext()
879
+ return retention === undefined ? { deleted: 0 } : outbox.sweep({ ...retention, ...(options.limit !== undefined ? { limit: options.limit } : {}) })
880
+ },
858
881
  async requeueFailed(options) {
859
882
  requireContext()
860
883
  nonEmpty('by', options.by)