pipework 0.257.0 → 0.258.1

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.
Files changed (51) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/cli/commands/alerts.js +2 -2
  3. package/dist/cli/commands/alerts.js.map +1 -1
  4. package/dist/cli/commands/db.js +1 -1
  5. package/dist/cli/commands/db.js.map +1 -1
  6. package/dist/core/validation/connectivity.d.ts.map +1 -1
  7. package/dist/core/validation/connectivity.js +1 -0
  8. package/dist/core/validation/connectivity.js.map +1 -1
  9. package/dist/core/validation/queue-tables.d.ts.map +1 -1
  10. package/dist/core/validation/queue-tables.js +4 -3
  11. package/dist/core/validation/queue-tables.js.map +1 -1
  12. package/dist/core/validation/rls-role.d.ts.map +1 -1
  13. package/dist/core/validation/rls-role.js +1 -0
  14. package/dist/core/validation/rls-role.js.map +1 -1
  15. package/dist/data/db/query-watch.js +1 -1
  16. package/dist/data/db/query-watch.js.map +1 -1
  17. package/dist/data/db/reservation.d.ts.map +1 -1
  18. package/dist/data/db/reservation.js +1 -0
  19. package/dist/data/db/reservation.js.map +1 -1
  20. package/dist/infra/email/inbound.d.ts +2 -0
  21. package/dist/infra/email/inbound.d.ts.map +1 -1
  22. package/dist/infra/email/inbound.js +1 -0
  23. package/dist/infra/email/inbound.js.map +1 -1
  24. package/dist/infra/support/handle.d.ts +2 -2
  25. package/dist/infra/support/handle.d.ts.map +1 -1
  26. package/dist/infra/support/handle.js +4 -2
  27. package/dist/infra/support/handle.js.map +1 -1
  28. package/dist/infra/support/namespace.d.ts.map +1 -1
  29. package/dist/test/reap.js +1 -1
  30. package/dist/test/reap.js.map +1 -1
  31. package/dist/test/reset.js +1 -1
  32. package/dist/test/reset.js.map +1 -1
  33. package/dist/test/setup.d.ts.map +1 -1
  34. package/dist/test/setup.js +6 -5
  35. package/dist/test/setup.js.map +1 -1
  36. package/dist/test/watch.js +3 -2
  37. package/dist/test/watch.js.map +1 -1
  38. package/package.json +1 -1
  39. package/src/cli/commands/alerts.ts +2 -2
  40. package/src/cli/commands/db.ts +1 -1
  41. package/src/core/validation/connectivity.ts +1 -0
  42. package/src/core/validation/queue-tables.ts +4 -3
  43. package/src/core/validation/rls-role.ts +1 -0
  44. package/src/data/db/query-watch.ts +1 -1
  45. package/src/data/db/reservation.ts +1 -0
  46. package/src/infra/email/inbound.ts +3 -0
  47. package/src/infra/support/handle.ts +5 -4
  48. package/src/test/reap.ts +1 -1
  49. package/src/test/reset.ts +1 -1
  50. package/src/test/setup.ts +6 -5
  51. package/src/test/watch.ts +3 -2
@@ -200,6 +200,7 @@ export async function queueTableProblems(url: string, timeoutMs: number | null =
200
200
  const { default: postgres } = await import('postgres')
201
201
  const sql = postgres(url, {
202
202
  max: 1,
203
+ fetch_types: false,
203
204
  idle_timeout: 1,
204
205
  ...probeConnectTimeout(timeoutMs),
205
206
  onnotice: () => {},
@@ -208,7 +209,7 @@ export async function queueTableProblems(url: string, timeoutMs: number | null =
208
209
  try {
209
210
  const presence = await sql<{ name: string; present: boolean }[]>`
210
211
  SELECT t.name, to_regclass('public.' || quote_ident(t.name)) IS NOT NULL AS present
211
- FROM unnest(${tables}::text[]) AS t(name)
212
+ FROM jsonb_array_elements_text(${sql.json(tables)}) AS t(name)
212
213
  `
213
214
  const present = new Set(presence.filter(r => r.present).map(r => r.name))
214
215
 
@@ -240,12 +241,12 @@ export async function queueTableProblems(url: string, timeoutMs: number | null =
240
241
  FROM pg_attribute a
241
242
  JOIN pg_class c ON c.oid = a.attrelid
242
243
  JOIN pg_namespace n ON n.oid = c.relnamespace
243
- WHERE n.nspname = 'public' AND c.relname = ANY(${[...present]}::text[])
244
+ WHERE n.nspname = 'public' AND c.relname IN (SELECT jsonb_array_elements_text(${sql.json([...present])}))
244
245
  AND a.attnum > 0 AND NOT a.attisdropped
245
246
  ) AS columns,
246
247
  (
247
248
  SELECT coalesce(json_agg(json_build_object('name', t, 'oid', to_regtype(t)::oid::int)), '[]'::json)
248
- FROM unnest(${typeNames}::text[]) AS t
249
+ FROM jsonb_array_elements_text(${sql.json(typeNames)}) AS t
249
250
  ) AS types
250
251
  `
251
252
  for (const column of row?.columns ?? []) {
@@ -39,6 +39,7 @@ export async function probeRlsRole(url: string, timeoutMs: number | null = 5000)
39
39
  const { default: postgres } = await import('postgres')
40
40
  const sql = postgres(url, {
41
41
  max: 1,
42
+ fetch_types: false,
42
43
  idle_timeout: 1,
43
44
  ...probeConnectTimeout(timeoutMs),
44
45
  onnotice: () => {},
@@ -49,7 +49,7 @@ export function createStatementWatch(options: StatementWatchOptions): StatementW
49
49
  function sampleBackend(sqlText: string, startedMs: number): Promise<BackendSample> {
50
50
  if (sampling || closed) return Promise.resolve(UNKNOWN_BACKEND)
51
51
  sampling = true
52
- sampler ??= postgres(url, { max: 1, idle_timeout: 3, onnotice: () => {}, connect_timeout: 5 })
52
+ sampler ??= postgres(url, { max: 1, idle_timeout: 3, onnotice: () => {}, connect_timeout: 5, fetch_types: false })
53
53
  const client = sampler
54
54
  const run = client.unsafe<Array<{ pid: number; state: string | null; wait_event_type: string | null; wait_event: string | null }>>(
55
55
  `SELECT pid, state, wait_event_type, wait_event FROM pg_stat_activity
@@ -51,6 +51,7 @@ export function createReservationPublisher(): ReservationPublisher {
51
51
  if (!entry.client) {
52
52
  entry.client = postgres(entry.url, {
53
53
  max: 1,
54
+ fetch_types: false,
54
55
  idle_timeout: 0,
55
56
  onnotice: () => {},
56
57
  connection: { application_name: value },
@@ -96,6 +96,8 @@ export interface ReceivedEmail {
96
96
  * and `from.address` is non-empty; a PASS on a message with an ambiguous From says nothing about any one author.
97
97
  */
98
98
  readonly verdicts: EmailVerdicts | null
99
+ /** Which inbound source recorded the message; only the folder stand-in has no verdicts. */
100
+ readonly source: 'ses' | 'folder'
99
101
  /** The envelope recipients SES accepted the message for; for the stand-in, the To and Cc addresses. */
100
102
  readonly recipients: readonly string[]
101
103
  }
@@ -493,6 +495,7 @@ export function createInbound(config: InboundConfig): Inbound {
493
495
  contentId: a.contentId, inline: a.inline, file: null,
494
496
  })),
495
497
  verdicts, recipients,
498
+ source: row['source'] as 'ses' | 'folder',
496
499
  }
497
500
 
498
501
  let tenantId = (row['tenant_id'] as string | null) ?? null
@@ -65,7 +65,7 @@ export interface SupportConfig {
65
65
  /**
66
66
  * Decides what `receiveEmail` does with a message. Default: virus verdict `FAIL` drops it; DMARC `FAIL`, or SPF and
67
67
  * DKIM both `FAIL`, separates it (the From address is probably forged, so it must not add text to a ticket an agent
68
- * is reading: it always opens a new ticket); anything else, including no verdicts (the folder stand-in), is accepted. A message it
68
+ * is reading: it always opens a new ticket); anything else, including no verdicts, is accepted (that is not trust: it only decides drop or separate). A message it
69
69
  * separates never names a user: `resolveContact` is not called for it.
70
70
  */
71
71
  readonly screen?: (email: ReceivedEmail) => ScreenVerdict
@@ -95,7 +95,7 @@ export interface SupportConfig {
95
95
  /**
96
96
  * With `joinThread: 'conversation'` (setting it with `'contact'` throws at create): whether a message from a sender
97
97
  * other than the ticket's contact joins the ticket found. The contact's own message always joins. `'authentic'`
98
- * (default): an email joins only when its DMARC verdict is `PASS`, or when it has no verdicts (the folder stand-in);
98
+ * (default): an email joins only when its DMARC verdict is `PASS`; null verdicts count as authentic only from the folder source (an SES mail without verdicts does not join);
99
99
  * `receive` (chat/api) joins, since the app vouched for the caller. `'any'`: joins. A function decides itself, with
100
100
  * `email: null` for `receive`. A refused message is handled like one `screen` separates: it opens its own ticket,
101
101
  * names no user, and its `receive` audit has `separated: true` and `reason: 'not-authentic'`. It never touches the
@@ -535,8 +535,9 @@ export function createSupport(config: SupportConfig): Support {
535
535
  const mayJoin = (t: Ticket): boolean => {
536
536
  if (t.contactEmail === m.contactEmail || joinOthers === 'any') return true
537
537
  if (typeof joinOthers === 'function') return joinOthers({ email: m.email ?? null, ticket: t })
538
- const v = m.email?.verdicts
539
- return m.email == null || v == null || v.dmarc === 'PASS'
538
+ if (m.email == null) return true
539
+ const v = m.email.verdicts
540
+ return v == null ? m.email.source === 'folder' : v.dmarc === 'PASS'
540
541
  }
541
542
  let ticket: Ticket | null = null
542
543
  if (m.ticketId !== null) {
package/src/test/reap.ts CHANGED
@@ -247,7 +247,7 @@ export async function reapConfiguredTestDatabases(instance: Manifold, options: R
247
247
  const droppedDatabases: string[] = []
248
248
  const skippedDatabases: string[] = []
249
249
  for (const adminUrl of adminUrls) {
250
- const client = postgres(adminUrl, { max: 1, onnotice: () => {} })
250
+ const client = postgres(adminUrl, { max: 1, fetch_types: false, onnotice: () => {} })
251
251
  try {
252
252
  const result = await reapOrphanTestDatabases(client, new Set(), options)
253
253
  droppedDatabases.push(...result.droppedDatabases)
package/src/test/reset.ts CHANGED
@@ -110,7 +110,7 @@ async function rlsAccess(q: Query, wanted: ReadonlySet<string>): Promise<{ lift:
110
110
  }
111
111
 
112
112
  function connect(url: string): postgres.Sql {
113
- return postgres(url, { max: 1, onnotice: () => {}, idle_timeout: 0, max_lifetime: 0 })
113
+ return postgres(url, { max: 1, fetch_types: false, onnotice: () => {}, idle_timeout: 0, max_lifetime: 0 })
114
114
  }
115
115
 
116
116
  async function readCopy(sql: Query, table: string): Promise<Buffer> {
package/src/test/setup.ts CHANGED
@@ -238,10 +238,10 @@ async function runSetup(instance: Manifold, tracker: PhaseTracker): Promise<Map<
238
238
  async function prepareRun(adminUrl: string, liveTemplates: ReadonlySet<string>, tracker: PhaseTracker): Promise<void> {
239
239
  runAdminUrl = adminUrl
240
240
  if (adminClient === null) {
241
- adminClient = postgres(adminUrl, { max: 1, onnotice: () => {} })
241
+ adminClient = postgres(adminUrl, { max: 1, fetch_types: false, onnotice: () => {} })
242
242
  }
243
243
  if (lockClient === null) {
244
- lockClient = postgres(adminUrl, { max: 1, idle_timeout: 0, max_lifetime: 0, onnotice: () => {} })
244
+ lockClient = postgres(adminUrl, { max: 1, fetch_types: false, idle_timeout: 0, max_lifetime: 0, onnotice: () => {} })
245
245
  await lockClient`SELECT pg_advisory_lock(${runLockKey(runNonce).toString()}::bigint)`
246
246
  }
247
247
  armWorkerShutdown()
@@ -416,6 +416,7 @@ async function templateIsReady(plan: TemplatePlan): Promise<boolean> {
416
416
  async function readTemplateMarker(plan: TemplatePlan): Promise<boolean> {
417
417
  const client = postgres(siblingUrl(plan.dbConfig, plan.templateDb), {
418
418
  max: 1,
419
+ fetch_types: false,
419
420
  onnotice: () => {},
420
421
  })
421
422
  try {
@@ -782,7 +783,7 @@ async function provisionTestSystemPolicy(
782
783
  beat: (detail: string) => void,
783
784
  ): Promise<void> {
784
785
  for (const url of migrateUrl === undefined ? [templateUrl] : [templateUrl, migrateUrl]) {
785
- const client = postgres(url, { max: 1, onnotice: () => {} })
786
+ const client = postgres(url, { max: 1, fetch_types: false, onnotice: () => {} })
786
787
  try {
787
788
  const tables = await client<{ ident: string; name: string }[]>`
788
789
  SELECT c.oid::regclass::text AS ident, c.relname AS name
@@ -1060,7 +1061,7 @@ async function provisionTemplateRole(
1060
1061
  `END $$`,
1061
1062
  )))
1062
1063
 
1063
- const templateClient = postgres(templateUrl, { max: 1, onnotice: () => {} })
1064
+ const templateClient = postgres(templateUrl, { max: 1, fetch_types: false, onnotice: () => {} })
1064
1065
  const templateDb = drizzle(templateClient)
1065
1066
 
1066
1067
  await templateDb.execute(sql.raw(`GRANT USAGE ON SCHEMA public TO ${roleIdent}`))
@@ -1089,7 +1090,7 @@ async function provisionTemplateRole(
1089
1090
  // grantor grants what it owns and nothing else.
1090
1091
  await grantOwnedObjects(templateClient, roleIdent, APP_ROLE_GRANTS, beat)
1091
1092
 
1092
- const migrateClient = postgres(migrateUrl, { max: 1, onnotice: () => {} })
1093
+ const migrateClient = postgres(migrateUrl, { max: 1, fetch_types: false, onnotice: () => {} })
1093
1094
  try {
1094
1095
  await grantOwnedObjects(migrateClient, roleIdent, APP_ROLE_GRANTS, beat)
1095
1096
  // The test login is the role tests connect as, and it no longer owns what
package/src/test/watch.ts CHANGED
@@ -74,7 +74,8 @@ async function sampleLine(target: TestWatchTarget): Promise<string> {
74
74
  }
75
75
 
76
76
  async function backends(adminUrl: string, databases: string[]): Promise<string[]> {
77
- const client = postgres(adminUrl, { max: 1, onnotice: () => {}, connect_timeout: 5 })
77
+ // A probe ended with a destroy timeout must not have an un-awaited type fetch in flight.
78
+ const client = postgres(adminUrl, { max: 1, onnotice: () => {}, connect_timeout: 5, fetch_types: false })
78
79
  try {
79
80
  const rows = await Promise.race([
80
81
  client<BackendRow[]>`
@@ -82,7 +83,7 @@ async function backends(adminUrl: string, databases: string[]): Promise<string[]
82
83
  round(extract(epoch FROM (now() - state_change))::numeric, 1)::text AS state_s,
83
84
  pg_blocking_pids(pid)::text AS blocked_by, query
84
85
  FROM pg_stat_activity
85
- WHERE datname = ANY(${databases}) AND pid <> pg_backend_pid()
86
+ WHERE datname IN (SELECT jsonb_array_elements_text(${client.json(databases)})) AND pid <> pg_backend_pid()
86
87
  ORDER BY datname, pid`,
87
88
  new Promise<never>((_, reject) => {
88
89
  setTimeout(() => reject(new Error('pg_stat_activity query timed out')), WATCH_QUERY_TIMEOUT_MS).unref()