better-ship 0.29.1 → 0.30.2

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 (49) hide show
  1. package/dist/{application-C_xN_1pa.js → application-B5hnNUdI.js} +9 -9
  2. package/dist/application-B5hnNUdI.js.map +1 -0
  3. package/dist/application.d.ts +1 -1
  4. package/dist/application.js +1 -1
  5. package/dist/bin/notify-ci.js +1 -1
  6. package/dist/cloudflare/queue.d.ts +1 -1
  7. package/dist/cloudflare/queue.js +2 -2
  8. package/dist/cloudflare/queue.js.map +1 -1
  9. package/dist/cloudflare.d.ts +2 -2
  10. package/dist/cloudflare.js +2 -2
  11. package/dist/{index-lS_g1olK.d.ts → index-CbvNd665.d.ts} +5 -6
  12. package/dist/{index-lS_g1olK.d.ts.map → index-CbvNd665.d.ts.map} +1 -1
  13. package/dist/{metrics-cv9P5nvk.d.ts → metrics-DXhrc6ii.d.ts} +2 -2
  14. package/dist/{metrics-cv9P5nvk.d.ts.map → metrics-DXhrc6ii.d.ts.map} +1 -1
  15. package/dist/{outbox-repository-options-Ww0kAgbq.js → outbox-repository-options-BNpCWTht.js} +2 -2
  16. package/dist/{outbox-repository-options-Ww0kAgbq.js.map → outbox-repository-options-BNpCWTht.js.map} +1 -1
  17. package/dist/{outbox-repository-options-15Sv9WnG.d.ts → outbox-repository-options-gk3JOjLB.d.ts} +2 -2
  18. package/dist/{outbox-repository-options-15Sv9WnG.d.ts.map → outbox-repository-options-gk3JOjLB.d.ts.map} +1 -1
  19. package/dist/postgres.d.ts +28 -10
  20. package/dist/postgres.d.ts.map +1 -1
  21. package/dist/postgres.js +81 -15
  22. package/dist/postgres.js.map +1 -1
  23. package/dist/posthog/metrics.d.ts +1 -1
  24. package/dist/posthog/server.d.ts +2 -2
  25. package/dist/resend.d.ts +1 -1
  26. package/dist/sentry/server.d.ts +1 -1
  27. package/dist/{slack-webhook-j1FCPeu8.js → slack-webhook-DSxFrjE9.js} +2 -2
  28. package/dist/slack-webhook-DSxFrjE9.js.map +1 -0
  29. package/dist/slack.js +1 -1
  30. package/dist/tanstack.js +23 -20
  31. package/dist/tanstack.js.map +1 -1
  32. package/dist/testing/outbox-repository.d.ts +1 -1
  33. package/package.json +7 -2
  34. package/src/application/message-bus.ts +17 -13
  35. package/src/application/transaction.ts +0 -1
  36. package/src/application/unit-of-work.ts +1 -1
  37. package/src/infrastructure/cloudflare/queue/queue-client.ts +1 -1
  38. package/src/infrastructure/postgres/classify-postgres-error.ts +44 -16
  39. package/src/infrastructure/postgres/index.ts +1 -0
  40. package/src/infrastructure/postgres/postgres-client.ts +45 -0
  41. package/src/infrastructure/postgres/postgres-database-execution.ts +11 -2
  42. package/src/infrastructure/postgres/postgres-database-types.ts +6 -5
  43. package/src/infrastructure/postgres/postgres-outbox-repository.ts +1 -1
  44. package/src/infrastructure/postgres/postgres-transaction.ts +16 -6
  45. package/src/infrastructure/slack/ci-message.ts +1 -4
  46. package/src/infrastructure/tanstack/resolve-failure.ts +34 -5
  47. package/src/infrastructure/tanstack/to-error-payload.ts +2 -21
  48. package/dist/application-C_xN_1pa.js.map +0 -1
  49. package/dist/slack-webhook-j1FCPeu8.js.map +0 -1
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://www.schemastore.org/package.json",
3
3
  "name": "better-ship",
4
- "version": "0.29.1",
4
+ "version": "0.30.2",
5
5
  "description": "Foundation for applications on Cloudflare Workers: logger, errors, message bus, infrastructure adapters, and UI primitives",
6
6
  "license": "Apache-2.0",
7
7
  "repository": {
@@ -88,14 +88,15 @@
88
88
  "@tanstack/start-client-core": "^1.170.34",
89
89
  "@tanstack/start-storage-context": "^1.167.36",
90
90
  "@types/node": "^26.6.3",
91
+ "@types/pg": "^8.23.1",
91
92
  "@types/react": "^19.3.0",
92
93
  "@vitest/browser-playwright": "^4.1.11",
93
94
  "auth": "^1.7.7",
94
95
  "better-auth": "^1.7.7",
95
96
  "drizzle-kit": "^0.31.11",
96
97
  "drizzle-orm": "^0.45.3",
98
+ "pg": "^8.23.1",
97
99
  "playwright": "^1.63.0",
98
- "postgres": "^3.4.9",
99
100
  "posthog-js": "^1.435.6",
100
101
  "posthog-node": "^5.55.0",
101
102
  "react": "^19.3.0",
@@ -116,6 +117,7 @@
116
117
  "@tanstack/react-start": "^1.168.60",
117
118
  "better-auth": "^1.7.7",
118
119
  "drizzle-orm": "^0.45.3",
120
+ "pg": "^8.23.1",
119
121
  "posthog-js": "^1.435.6",
120
122
  "posthog-node": "^5.55.0",
121
123
  "react": "^19.3.0",
@@ -161,6 +163,9 @@
161
163
  },
162
164
  "drizzle-orm": {
163
165
  "optional": true
166
+ },
167
+ "pg": {
168
+ "optional": true
164
169
  }
165
170
  },
166
171
  "engines": {
@@ -65,24 +65,28 @@ export class MessageBus<Deps, TRegistry extends Handlers<Deps>> implements IMess
65
65
  }
66
66
 
67
67
  /**
68
- * Every subscriber runs to completion. One failure is rethrown as is. Several are thrown as
69
- * one `AggregateError`. A `MessageAlreadyProcessedError` means that subscriber already committed on
70
- * an earlier delivery, so it counts as delivered.
68
+ * Subscribers run one after another, so one unit of work finishes before the next starts.
69
+ * Every subscriber still runs. One failure is rethrown as is. Several are thrown as one `AggregateError`.
70
+ * A `MessageAlreadyProcessedError` means that subscriber already committed on an earlier delivery, so it counts as delivered.
71
71
  */
72
72
  private async handleEvent(event: DomainEvent): Promise<void> {
73
73
  const subscriptions = this.registry.events[event.name] ?? []
74
- const outcomes = await Promise.allSettled(
75
- subscriptions.map((subscription) => subscription(event, this.deps)),
76
- )
77
74
  const failures: unknown[] = []
78
- for (const [subscriber, outcome] of outcomes.entries()) {
79
- if (outcome.status === 'fulfilled') continue
80
- if (outcome.reason instanceof MessageAlreadyProcessedError) {
81
- // The bus swallows this error, so this line is the only record that the skip happened.
82
- logger.info('duplicate_subscriber_ignored', { event: event.name, id: event.id, subscriber })
83
- continue
75
+ for (const [subscriber, subscription] of subscriptions.entries()) {
76
+ try {
77
+ await subscription(event, this.deps)
78
+ } catch (reason) {
79
+ if (reason instanceof MessageAlreadyProcessedError) {
80
+ // The bus swallows this error, so this line is the only record that the skip happened.
81
+ logger.info('duplicate_subscriber_ignored', {
82
+ event: event.name,
83
+ id: event.id,
84
+ subscriber,
85
+ })
86
+ continue
87
+ }
88
+ failures.push(reason)
84
89
  }
85
- failures.push(outcome.reason)
86
90
  }
87
91
  if (failures.length === 1) throw failures[0]
88
92
  if (failures.length > 1) {
@@ -1,7 +1,6 @@
1
1
  /**
2
2
  * Supplies `Tx` to participating repositories and resolves after their writes commit. Failure rolls back or discards writes.
3
3
  * Nested calls reject with `UnitOfWorkAlreadyActiveError` before opening another boundary.
4
- * Independent concurrent calls are allowed.
5
4
  */
6
5
  export interface ITransaction<Tx> {
7
6
  transaction<T>(work: (tx: Tx) => Promise<T>): Promise<T>
@@ -9,7 +9,7 @@ import type { ITransaction } from './transaction.ts'
9
9
  /**
10
10
  * Commits the processed-message record, repository writes, and emitted events together before resolving.
11
11
  * An existing record rejects with `MessageAlreadyProcessedError`; external effects must run through the outbox after commit.
12
- * Nested calls reject with `UnitOfWorkAlreadyActiveError`; independent concurrent calls are allowed.
12
+ * Nested calls reject with `UnitOfWorkAlreadyActiveError`.
13
13
  */
14
14
  export interface IUnitOfWork<Repos> {
15
15
  run<T>(messageId: MessageId, work: (repos: Repos) => Promise<T>): Promise<T>
@@ -116,7 +116,7 @@ export class QueueClient<
116
116
  ): Promise<ReadonlyArray<Sent>> {
117
117
  const sized = sizeMessages(messages)
118
118
  await this.sendSizedBatch(sized.filter((item) => !isOversized(item)))
119
- return sized.filter(isOversized).map((item) => item.message)
119
+ return sized.flatMap((item) => (isOversized(item) ? [item.message] : []))
120
120
  }
121
121
 
122
122
  async sendToDlq(message: Message): Promise<void> {
@@ -5,6 +5,7 @@ import { AppError, DatabaseError, type FailureClassification } from '@/core'
5
5
  const TRANSIENT_CODES = new Set([
6
6
  '40001',
7
7
  '40P01',
8
+ '58000',
8
9
  '08000',
9
10
  '08001',
10
11
  '08003',
@@ -28,6 +29,10 @@ const DRIVER_CODES = new Set([
28
29
  'CONNECTION_DESTROYED',
29
30
  ])
30
31
  const TERMINAL_CLASSES = new Set(['0A', '22', '23', '25', '28', '3D', '3F', '42', '54'])
32
+ const DEAD_SOCKET_MESSAGES = new Set([
33
+ 'Connection terminated unexpectedly',
34
+ 'Client has encountered a connection error and is not queryable',
35
+ ])
31
36
 
32
37
  /* oxlint-disable anti-slop/no-runtime-typeof -- Raw driver exceptions have no shared schema; this boundary validates the fields it exposes. */
33
38
  /** Read SQLSTATE or driver codes; unknown callback exceptions remain unchanged. */
@@ -49,40 +54,63 @@ export function classifyPostgresError(
49
54
  : undefined
50
55
  const serverError =
51
56
  error instanceof Error &&
52
- error.name === 'PostgresError' &&
57
+ (error.name === 'PostgresError' || error.name === 'error') &&
53
58
  code !== undefined &&
54
59
  /^[0-9A-Z]{5}$/.test(code)
55
60
  const driverError = code !== undefined && DRIVER_CODES.has(code)
61
+ const deadSocket = error instanceof Error && DEAD_SOCKET_MESSAGES.has(error.message)
56
62
  if (!(cause instanceof DrizzleQueryError) && !serverError && !driverError) return undefined
57
63
 
58
64
  return new DatabaseError(
59
65
  operation,
60
- classificationOf(code),
66
+ classificationOf(code, deadSocket),
61
67
  {
62
68
  database: 'postgres',
63
69
  code,
64
- table_name:
65
- error instanceof Error && 'table_name' in error && typeof error.table_name === 'string'
66
- ? error.table_name
67
- : undefined,
68
- column_name:
69
- error instanceof Error && 'column_name' in error && typeof error.column_name === 'string'
70
- ? error.column_name
71
- : undefined,
70
+ table_name: error instanceof Error ? text(error, 'table', 'table_name') : undefined,
71
+ column_name: error instanceof Error ? text(error, 'column', 'column_name') : undefined,
72
72
  constraint_name:
73
- error instanceof Error &&
74
- 'constraint_name' in error &&
75
- typeof error.constraint_name === 'string'
76
- ? error.constraint_name
77
- : undefined,
73
+ error instanceof Error ? text(error, 'constraint', 'constraint_name') : undefined,
78
74
  },
79
75
  { cause },
80
76
  )
81
77
  }
82
78
 
79
+ function text(error: Error, pgName: string, jsName: string): string | undefined {
80
+ return read(error, pgName) ?? read(error, jsName)
81
+ }
82
+
83
+ function read(error: Error, name: string): string | undefined {
84
+ switch (name) {
85
+ case 'table':
86
+ return 'table' in error && typeof error.table === 'string' ? error.table : undefined
87
+ case 'column':
88
+ return 'column' in error && typeof error.column === 'string' ? error.column : undefined
89
+ case 'constraint':
90
+ return 'constraint' in error && typeof error.constraint === 'string'
91
+ ? error.constraint
92
+ : undefined
93
+ case 'table_name':
94
+ return 'table_name' in error && typeof error.table_name === 'string'
95
+ ? error.table_name
96
+ : undefined
97
+ case 'column_name':
98
+ return 'column_name' in error && typeof error.column_name === 'string'
99
+ ? error.column_name
100
+ : undefined
101
+ case 'constraint_name':
102
+ return 'constraint_name' in error && typeof error.constraint_name === 'string'
103
+ ? error.constraint_name
104
+ : undefined
105
+ default:
106
+ return undefined
107
+ }
108
+ }
109
+
83
110
  /* oxlint-enable anti-slop/no-runtime-typeof */
84
111
 
85
- function classificationOf(code: string | undefined): FailureClassification {
112
+ function classificationOf(code: string | undefined, deadSocket: boolean): FailureClassification {
113
+ if (deadSocket) return 'transient'
86
114
  if (code === undefined) return 'unknown'
87
115
  if (TRANSIENT_CODES.has(code)) return 'transient'
88
116
  if (TERMINAL_CODES.has(code) || TERMINAL_CLASSES.has(code.slice(0, 2))) return 'terminal'
@@ -10,6 +10,7 @@ export type {
10
10
  PostgresTx,
11
11
  } from './postgres-database-types.ts'
12
12
  export { DatabaseError, type DatabaseFailure } from '@/core'
13
+ export { connectPostgres } from './postgres-client.ts'
13
14
  export { executeDatabase } from './postgres-database-execution.ts'
14
15
  export { classifyPostgresError } from './classify-postgres-error.ts'
15
16
  export { eventCollector } from '../unit-of-work-context.ts'
@@ -0,0 +1,45 @@
1
+ import { drizzle } from 'drizzle-orm/node-postgres'
2
+ import { Client } from 'pg'
3
+
4
+ import { DatabaseError } from '@/core'
5
+
6
+ import type { DrizzleSchema } from '../drizzle-schema.ts'
7
+ import { classifyPostgresError } from './classify-postgres-error.ts'
8
+ import type { PostgresConnection, PostgresDb } from './postgres-database-types.ts'
9
+
10
+ /**
11
+ * Open one Hyperdrive session for this invocation.
12
+ * `connectionString` is the Hyperdrive binding's `connectionString`.
13
+ * Create the session inside the handler, the queue consumer, the schedule, the Workflow step, or the Durable Object method.
14
+ * The caller does not call `client.end()` and does not reuse the client.
15
+ * Requires the `pg` package.
16
+ */
17
+ export async function connectPostgres<Schema extends DrizzleSchema>(
18
+ connectionString: string,
19
+ schema: Schema,
20
+ ): Promise<{ readonly db: PostgresDb<Schema> & { readonly $client: PostgresConnection } }> {
21
+ const client = new Client({ connectionString })
22
+ // An idle socket can emit `error` after connect with no query waiting. The next query reports it.
23
+ client.on('error', () => {})
24
+ try {
25
+ await client.connect()
26
+ } catch (cause) {
27
+ throw classifyPostgresError(cause, 'connect') ?? connectFailure(cause)
28
+ }
29
+ return { db: drizzle(client, { schema }) }
30
+ }
31
+
32
+ function connectFailure(cause: unknown): DatabaseError {
33
+ return new DatabaseError(
34
+ 'connect',
35
+ 'transient',
36
+ {
37
+ database: 'postgres',
38
+ code: undefined,
39
+ table_name: undefined,
40
+ column_name: undefined,
41
+ constraint_name: undefined,
42
+ },
43
+ { cause },
44
+ )
45
+ }
@@ -4,9 +4,14 @@ import { DatabaseError } from '@/core'
4
4
 
5
5
  import { classifyPostgresError } from './classify-postgres-error.ts'
6
6
 
7
+ /** SQLSTATE values that leave the pg socket usable, so the same client can run the statement again. */
8
+ const RETRYABLE_ON_SESSION = new Set(['40001', '40P01', '58000'])
9
+
7
10
  /**
8
11
  * Convert database failures and preserve other exceptions. The default executes once.
9
- * Opt-in transient retries allow three attempts with full jitter and backoff caps of 250 ms and 500 ms.
12
+ * Opt-in retries allow three attempts for serialization failure, deadlock, and a Hyperdrive pool acquire.
13
+ * A dropped socket stays transient and is not retried here: pg cannot reconnect the client.
14
+ * Delays use full jitter with backoff caps of 250 ms and 500 ms.
10
15
  */
11
16
  export async function executeDatabase<T>(
12
17
  operation: string,
@@ -25,7 +30,11 @@ export async function executeDatabase<T>(
25
30
  backoff: 'exponential',
26
31
  jitter: true,
27
32
  shouldRetry: (error) =>
28
- error instanceof DatabaseError && error.classification === 'transient',
33
+ error instanceof DatabaseError &&
34
+ error.classification === 'transient' &&
35
+ error.details.database === 'postgres' &&
36
+ error.details.code !== undefined &&
37
+ RETRYABLE_ON_SESSION.has(error.details.code),
29
38
  },
30
39
  },
31
40
  )
@@ -1,13 +1,14 @@
1
1
  import type { ExtractTablesWithRelations } from 'drizzle-orm'
2
- import type { drizzle, PostgresJsDatabase, PostgresJsTransaction } from 'drizzle-orm/postgres-js'
2
+ import type { NodePgDatabase, NodePgTransaction } from 'drizzle-orm/node-postgres'
3
+ import type { Client } from 'pg'
3
4
 
4
5
  import type { DrizzleSchema } from '../drizzle-schema.ts'
5
6
 
6
- /** The postgres.js client passed to Drizzle, including clients configured through Hyperdrive. */
7
- export type PostgresConnection = ReturnType<typeof drizzle>['$client']
7
+ /** The pg client passed to Drizzle. One client belongs to one invocation. */
8
+ export type PostgresConnection = Client
8
9
 
9
10
  /** The application's Drizzle Postgres database, preserving its schema and relational query types. */
10
- export type PostgresDb<Schema extends DrizzleSchema> = PostgresJsDatabase<Schema>
11
+ export type PostgresDb<Schema extends DrizzleSchema> = NodePgDatabase<Schema>
11
12
 
12
13
  /** Exposes Drizzle query methods without insert, update, delete, transaction, or raw execution methods. */
13
14
  export type PostgresReadDb<Schema extends DrizzleSchema> = Pick<
@@ -16,7 +17,7 @@ export type PostgresReadDb<Schema extends DrizzleSchema> = Pick<
16
17
  >
17
18
 
18
19
  /** The native Drizzle transaction with the application's full schema and derived relation types. */
19
- export type PostgresDbTransaction<Schema extends DrizzleSchema> = PostgresJsTransaction<
20
+ export type PostgresDbTransaction<Schema extends DrizzleSchema> = NodePgTransaction<
20
21
  Schema,
21
22
  ExtractTablesWithRelations<Schema>
22
23
  >
@@ -102,7 +102,7 @@ export class PostgresOutboxRepository implements IOutboxRepository {
102
102
  async releaseClaim(claim: OutboxClaim, messages: ReadonlyArray<OutboxMessage>): Promise<void> {
103
103
  const now = this.now()
104
104
  for (const attempts of new Set(messages.map((row) => row.attempts))) {
105
- const ids = messages.filter((row) => row.attempts === attempts).map((row) => row.payload.id)
105
+ const ids = messages.flatMap((row) => (row.attempts === attempts ? [row.payload.id] : []))
106
106
  const delay = exponentialDelayCapMs(
107
107
  this.options.retryBaseDelayMs,
108
108
  this.options.retryMaxDelayMs,
@@ -1,4 +1,4 @@
1
- import type { ITransaction } from '@/application'
1
+ import { UnitOfWorkAlreadyActiveError, type ITransaction } from '@/application'
2
2
 
3
3
  import type { DrizzleSchema } from '../drizzle-schema.ts'
4
4
  import { runWithUnitOfWorkContext } from '../unit-of-work-context.ts'
@@ -6,17 +6,27 @@ import { executeDatabase } from './postgres-database-execution.ts'
6
6
  import type { PostgresDb, PostgresTx } from './postgres-database-types.ts'
7
7
 
8
8
  /**
9
- * The Postgres transaction boundary uses native Drizzle transactions through postgres.js.
10
- * Drizzle commits when `work` returns and rolls back when it throws.
9
+ * One Drizzle transaction on one pg client: `BEGIN`, the work, `COMMIT`, or `ROLLBACK` on a throw.
10
+ * A second call while this one is open throws. Overlap needs a second `connectPostgres`.
11
11
  */
12
12
  export class PostgresTransaction<Schema extends DrizzleSchema> implements ITransaction<
13
13
  PostgresTx<Schema>
14
14
  > {
15
+ private active = false
16
+
15
17
  constructor(private readonly db: PostgresDb<Schema>) {}
16
18
 
17
19
  transaction<T>(work: (tx: PostgresTx<Schema>) => Promise<T>): Promise<T> {
18
- return runWithUnitOfWorkContext('postgres', () =>
19
- executeDatabase('unit of work transaction', () => this.db.transaction((db) => work({ db }))),
20
- )
20
+ return runWithUnitOfWorkContext('postgres', async () => {
21
+ if (this.active) throw new UnitOfWorkAlreadyActiveError()
22
+ this.active = true
23
+ try {
24
+ return await executeDatabase('unit of work transaction', () =>
25
+ this.db.transaction((db) => work({ db })),
26
+ )
27
+ } finally {
28
+ this.active = false
29
+ }
30
+ })
21
31
  }
22
32
  }
@@ -222,10 +222,7 @@ function headlineFor(run: CiRun): string {
222
222
  }
223
223
 
224
224
  function namesWith(results: ReadonlyArray<CiStepResult>, outcome: CiOutcome): string {
225
- return results
226
- .filter((result) => result.outcome === outcome)
227
- .map((result) => result.name)
228
- .join(', ')
225
+ return results.flatMap((result) => (result.outcome === outcome ? [result.name] : [])).join(', ')
229
226
  }
230
227
 
231
228
  function linkElements(run: CiRun): ReadonlyArray<SlackElement> {
@@ -1,17 +1,46 @@
1
- import { AppError, createLogger, type ErrorTable, type ResolvedError } from '@/core'
1
+ import { z } from 'zod'
2
+
3
+ import {
4
+ AppError,
5
+ ValidationError,
6
+ createLogger,
7
+ type ErrorTable,
8
+ type ResolvedError,
9
+ } from '@/core'
2
10
  import type { ErrorPayloadOf } from '@/core'
3
11
 
4
12
  const logger = createLogger('error-boundary')
5
13
 
14
+ // TanStack serializes a validator's issues into `Error.message` as JSON.
15
+ const SerializedIssuesSchema = z
16
+ .array(z.object({ message: z.string(), path: z.array(z.union([z.string(), z.number()])) }))
17
+ .min(1)
18
+
19
+ // A live ZodError keeps `.issues`. TanStack leaves only JSON in `Error.message`.
20
+ function reconstructValidationError(cause: unknown): ValidationError | null {
21
+ if (cause instanceof z.ZodError) return ValidationError.fromZod(cause)
22
+ if (!(cause instanceof Error)) return null
23
+ let message: unknown
24
+ try {
25
+ message = JSON.parse(cause.message)
26
+ } catch {
27
+ return null
28
+ }
29
+ const parsed = SerializedIssuesSchema.safeParse(message)
30
+ return parsed.success ? ValidationError.fromIssues(parsed.data) : null
31
+ }
32
+
6
33
  /**
7
34
  * Resolve one failure through the table or fall back to the generic replacement, and log it once.
35
+ * A thrown ZodError, or issues serialized into an error message, becomes a ValidationError first.
8
36
  * Both transport converters call this, so one rule decides the log level: mapped is a warning, unmapped is an error with the cause.
9
37
  */
10
38
  export function resolveFailure<Entries>(
11
39
  table: ErrorTable<Entries>,
12
40
  cause: unknown,
13
41
  ): ResolvedError<ErrorPayloadOf<ErrorTable<Entries>>> {
14
- const resolved = table.resolve(cause)
42
+ const failure = reconstructValidationError(cause) ?? cause
43
+ const resolved = table.resolve(failure)
15
44
  if (resolved !== null) {
16
45
  logger.warn('handled_error', {
17
46
  tag: resolved.payload._tag,
@@ -19,7 +48,7 @@ export function resolveFailure<Entries>(
19
48
  })
20
49
  return resolved
21
50
  }
22
- const classification = cause instanceof AppError ? cause.classification : 'unknown'
23
- logger.error('unexpected_error', { error: cause, classification })
24
- return table.fallback(cause)
51
+ const classification = failure instanceof AppError ? failure.classification : 'unknown'
52
+ logger.error('unexpected_error', { error: failure, classification })
53
+ return table.fallback(failure)
25
54
  }
@@ -1,32 +1,13 @@
1
- import { z } from 'zod'
2
-
3
- import { ValidationError, type ErrorPayloadOf, type ErrorTable } from '@/core'
1
+ import type { ErrorPayloadOf, ErrorTable } from '@/core'
4
2
 
5
3
  import { resolveFailure } from './resolve-failure.ts'
6
4
 
7
- // TanStack serializes a validator's issues into `Error.message` as JSON.
8
- const SerializedIssuesSchema = z
9
- .array(z.object({ message: z.string(), path: z.array(z.union([z.string(), z.number()])) }))
10
- .min(1)
11
-
12
- function reconstructValidationError(cause: unknown): ValidationError | null {
13
- if (!(cause instanceof Error)) return null
14
- let message: unknown
15
- try {
16
- message = JSON.parse(cause.message)
17
- } catch {
18
- return null
19
- }
20
- const parsed = SerializedIssuesSchema.safeParse(message)
21
- return parsed.success ? ValidationError.fromIssues(parsed.data) : null
22
- }
23
-
24
5
  /**
25
6
  * Build the server-function converter for one error table.
26
7
  * The result is a plain object, because only own data survives serialization.
27
8
  */
28
9
  export function createErrorPayloadConverter<Entries>(table: ErrorTable<Entries>) {
29
10
  return function toErrorPayload(cause: unknown): ErrorPayloadOf<ErrorTable<Entries>> {
30
- return resolveFailure(table, reconstructValidationError(cause) ?? cause).payload
11
+ return resolveFailure(table, cause).payload
31
12
  }
32
13
  }
@@ -1 +0,0 @@
1
- {"version":3,"file":"application-C_xN_1pa.js","names":["logger"],"sources":["../src/application/analytics-service.ts","../src/application/metrics-service.ts","../src/application/email-service.ts","../src/application/record-email-event.ts","../src/application/message-already-processed.error.ts","../src/application/message-bus.ts","../src/application/outbox-relay.ts","../src/application/outbox-retention.ts","../src/application/unit-of-work.ts","../src/application/unit-of-work.errors.ts"],"sourcesContent":["import type { AnalyticsEventIdentity, AnalyticsEventInput, AnalyticsProperties } from '@/core'\n\n/** Product analytics without an SDK dependency, ported from Typist and Porte. */\nexport interface AnalyticsService<TEvent extends AnalyticsEventInput = AnalyticsEventInput> {\n /** Attach traits to one person. */\n identify(distinctId: string, properties?: AnalyticsProperties): void\n /** Merge a previous ID into this person; the application must establish that both identify the same person. */\n alias(distinctId: string, previousDistinctId: string): void\n /** Schedule an event for a person; returning does not confirm remote acceptance. */\n track(event: TEvent, distinctId: string, identity?: AnalyticsEventIdentity): void\n /** Schedule an event without creating a person profile for its distinct ID. */\n trackAnonymous(event: TEvent, distinctId: string, identity?: AnalyticsEventIdentity): void\n}\n\n/** Reports to nobody. The composition root selects this service when analytics is disabled. */\nexport const NULL_ANALYTICS: AnalyticsService = {\n identify: () => {},\n alias: () => {},\n track: () => {},\n trackAnonymous: () => {},\n}\n","/** A metric dimension; use a small, bounded value set and never user or request IDs. */\nexport type MetricAttributeValue = string | number | boolean\n\n/** Dimensions shared by observations in one time series. */\nexport type MetricAttributes = Readonly<Record<string, MetricAttributeValue>>\n\n/** Unit and dimensions for an operational measurement. */\nexport interface MetricOptions {\n readonly attributes?: MetricAttributes\n readonly unit?: string\n}\n\n/** Operational measurements, ported from Typist's metrics-service.ts; applications own metric names. */\nexport interface MetricsService<TName extends string = string> {\n /** Add a nonnegative value to a total; the default increment is one. */\n count(name: TName, value?: number, options?: MetricOptions): void\n /** Record a current value. */\n gauge(name: TName, value: number, options?: MetricOptions): void\n /** Record one observation in a distribution. */\n histogram(name: TName, value: number, options?: MetricOptions): void\n}\n\n/** Records nothing when the composition root disables metrics. */\nexport const NULL_METRICS_SERVICE: MetricsService = {\n count: () => {},\n gauge: () => {},\n histogram: () => {},\n}\n","import type { EmailIdempotencyKey } from '@/core'\n\n/** A media type such as `application/pdf`. */\nexport type MimeType = `${string}/${string}`\n\n/** A file to attach. Binary content is bytes; the adapter encodes it for the provider. */\nexport type EmailAttachment = {\n readonly filename: string\n readonly content: string | Uint8Array\n /** Optional MIME metadata; Resend accepts filename and content without it. */\n readonly contentType?: MimeType\n}\n\n/**\n * One transactional email. Resend accepts 50 recipients, 75 tags with ASCII names and values,\n * and 40 MB of attachments after base64. Sources: https://resend.com/docs/api-reference/emails/send-email\n * and https://resend.com/docs/dashboard/emails/tags\n */\nexport type OutboundEmail = {\n /** Override the default for this email, for example \"Display Name <address@domain.com>\". */\n readonly from?: string\n readonly to: string | readonly string[]\n readonly subject: string\n readonly html: string\n /** Optional stable identity of this send. Resend deduplicates supplied keys for 24 hours. */\n readonly idempotencyKey?: EmailIdempotencyKey\n /** Resend generates plain text from html when omitted. An empty string disables that generation. */\n readonly text?: string\n readonly replyTo?: string\n /** Labels the provider stores with the email and returns on every delivery event. */\n readonly tags?: Readonly<Record<string, string>>\n readonly attachments?: readonly EmailAttachment[]\n}\n\n/** Received email headers, without the body or attachments. */\nexport type ReceivedEmail = { readonly headers: Readonly<Record<string, string>> }\n\n/** One attachment of a received email. `downloadUrl` is a signed link that stops working at `expiresAt`. */\nexport type ReceivedAttachment = {\n readonly filename: string | null\n readonly contentType: string\n readonly size: number\n readonly downloadUrl: string\n readonly expiresAt: string\n}\n\n/** A received email's sender, plain text body, and attachments, for an application that reads mail it receives. */\nexport type ReceivedContent = {\n /** The sender as the email states it, for example \"Display Name <address@domain.com>\". */\n readonly from: string\n readonly text: string | null\n readonly attachments: readonly ReceivedAttachment[]\n}\n\n/** The provider accepted the email and assigned its id. */\nexport type EmailAccepted = { readonly providerId: string }\n\n/**\n * The adapter supplies the default sender and maps provider errors. Each email can override the sender.\n * A provider rejection throws `EmailServiceError`; unexpected SDK rejections propagate unchanged.\n */\nexport interface IEmailService {\n /** Resolve after provider acceptance; this does not confirm delivery to the recipient. */\n sendEmail(email: OutboundEmail): Promise<EmailAccepted>\n}\n\n/** Records every send for a test. Provider ids count up as `recorded-1`, `recorded-2`. */\nexport class RecordingEmailService implements IEmailService {\n /** Sends in call order, including repeated idempotency keys. */\n readonly sent: OutboundEmail[] = []\n\n /** Record the send before returning its provider id. */\n async sendEmail(email: OutboundEmail): Promise<EmailAccepted> {\n this.sent.push(email)\n return { providerId: `recorded-${this.sent.length}` }\n }\n}\n","import type { BounceType, DomainCommand, EmailAddress, RecordEmailEventPayload } from '@/core'\n\nimport type { IUnitOfWork } from './unit-of-work.ts'\n\n/** What a bounce records for one address. */\nexport type EmailBounceRecord = {\n readonly type: BounceType\n readonly subType: string | null\n}\n\n/**\n * The application's deliverability state per address. An implementation keeps the higher bounce\n * severity with `shouldUpdateBounce`, and treats an unknown address as nothing to record.\n */\nexport interface IEmailDeliverabilityStore {\n /** Keep the higher severity atomically. Equal severity permits replacement; the store owns timestamp ordering. */\n recordBounce(address: EmailAddress, bounce: EmailBounceRecord): Promise<void>\n /** Record a complaint without clearing existing suppression state. */\n recordComplaint(address: EmailAddress): Promise<void>\n}\n\n/** The repositories the handler needs from the unit of work. */\nexport type RecordEmailEventRepos = { readonly emailDeliverability: IEmailDeliverabilityStore }\n\n/** The container slice the handler reads. */\nexport type RecordEmailEventDeps = { readonly uow: IUnitOfWork<RecordEmailEventRepos> }\n\n/** The command, under whatever name the application registered the payload. */\nexport type RecordEmailEventCommand = DomainCommand & RecordEmailEventPayload\n\n/**\n * Record a bounce or a complaint for every recipient inside the command's unit of work.\n * The command id is the provider's event id, so a redelivered webhook is `MessageAlreadyProcessedError`.\n */\nexport async function recordEmailEvent(\n command: RecordEmailEventCommand,\n deps: RecordEmailEventDeps,\n): Promise<void> {\n await deps.uow.run(command.id, async ({ emailDeliverability }) => {\n for (const address of command.recipients) {\n if (command.event.type === 'bounced') {\n // oxlint-disable-next-line no-await-in-loop -- Finish each transaction write before starting another; a failure must leave no pending writes.\n await emailDeliverability.recordBounce(address, {\n type: command.event.bounceType,\n subType: command.event.bounceSubType,\n })\n } else {\n // oxlint-disable-next-line no-await-in-loop -- A rejected write must stop the transaction before another write starts.\n await emailDeliverability.recordComplaint(address)\n }\n }\n })\n}\n","import { AppError, DatabaseError, type MessageId } from '@/core'\n\n/** The message's effects already committed. A caller can skip duplicate delivery without repeating those effects. */\nexport class MessageAlreadyProcessedError extends AppError {\n readonly _tag = 'MessageAlreadyProcessedError'\n readonly classification = 'terminal'\n\n constructor(\n readonly messageId: MessageId,\n options?: { readonly cause: unknown },\n ) {\n super(`message ${messageId} already processed`, options)\n }\n\n /** Recognize only the processed-message primary key; preserve other failures unchanged at the call site. */\n static from(cause: unknown, messageId: MessageId): MessageAlreadyProcessedError | undefined {\n if (cause instanceof MessageAlreadyProcessedError) return cause\n if (!(cause instanceof DatabaseError)) return undefined\n const details = cause.details\n const duplicate =\n details.database === 'd1'\n ? details.kind === 'unique' &&\n details.table === 'processed_messages' &&\n details.column === 'message_id'\n : details.code === '23505' &&\n details.table_name === 'processed_messages' &&\n details.constraint_name === 'processed_messages_pkey'\n if (!duplicate) return undefined\n return new MessageAlreadyProcessedError(messageId, { cause })\n }\n}\n","import { panic } from 'better-result'\n\nimport {\n createLogger,\n unreachable,\n type DomainCommand,\n type DomainEvent,\n type DomainMessage,\n type DomainQuery,\n} from '@/core'\n\nimport { MessageAlreadyProcessedError } from './message-already-processed.error.ts'\nimport type { Handlers, MessageResult, RegistryMessage } from './registry.ts'\n\nconst logger = createLogger('message-bus')\n\n/** The port an entrypoint dispatches through. `AppDeps` names this as `IMessageBus<typeof registry>`. */\nexport interface IMessageBus<TRegistry extends Handlers<never>> {\n handle<TMessage extends DomainMessage & RegistryMessage<TRegistry>>(\n message: TMessage,\n ): Promise<MessageResult<TMessage, TRegistry>>\n}\n\n/**\n * Routes one message to its slot. The bus opens no transaction and keeps no outbox:\n * a handler that writes opens the unit of work itself.\n */\nexport class MessageBus<Deps, TRegistry extends Handlers<Deps>> implements IMessageBus<TRegistry> {\n constructor(\n private readonly deps: Deps,\n private readonly registry: TRegistry,\n ) {}\n\n /** Resolves to the handler's own value for a command or query, and to void for an event. */\n async handle<TMessage extends DomainMessage & RegistryMessage<TRegistry>>(\n message: TMessage,\n ): Promise<MessageResult<TMessage, TRegistry>> {\n // SAFETY: the slot for this name is what `MessageResult` names, and dispatch returns its value.\n return (await this.dispatch(message)) as MessageResult<TMessage, TRegistry>\n }\n\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- one table holds every slot's value; `handle` names it.\n private dispatch(message: DomainMessage): Promise<unknown> {\n if (message.type === 'command') return this.handleCommand(message)\n if (message.type === 'query') return this.handleQuery(message)\n if (message.type === 'event') return this.handleEvent(message)\n // A message parsed at a boundary cannot reach here; one that does must not resolve silently.\n return unreachable(message)\n }\n\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- see dispatch.\n private handleCommand(command: DomainCommand): Promise<unknown> {\n // `satisfies` proved the registry complete, so a missing slot is a broken registry object.\n const handler = this.registry.commands[command.name]\n if (handler === undefined) return panic(`No handler for command ${command.name}`)\n logger.debug('handling_command', { name: command.name, id: command.id })\n return handler(command, this.deps)\n }\n\n // oxlint-disable-next-line anti-slop/no-unknown-returns -- see dispatch.\n private handleQuery(query: DomainQuery): Promise<unknown> {\n const handler = this.registry.queries[query.name]\n if (handler === undefined) return panic(`No handler for query ${query.name}`)\n return handler(query, this.deps)\n }\n\n /**\n * Every subscriber runs to completion. One failure is rethrown as is. Several are thrown as\n * one `AggregateError`. A `MessageAlreadyProcessedError` means that subscriber already committed on\n * an earlier delivery, so it counts as delivered.\n */\n private async handleEvent(event: DomainEvent): Promise<void> {\n const subscriptions = this.registry.events[event.name] ?? []\n const outcomes = await Promise.allSettled(\n subscriptions.map((subscription) => subscription(event, this.deps)),\n )\n const failures: unknown[] = []\n for (const [subscriber, outcome] of outcomes.entries()) {\n if (outcome.status === 'fulfilled') continue\n if (outcome.reason instanceof MessageAlreadyProcessedError) {\n // The bus swallows this error, so this line is the only record that the skip happened.\n logger.info('duplicate_subscriber_ignored', { event: event.name, id: event.id, subscriber })\n continue\n }\n failures.push(outcome.reason)\n }\n if (failures.length === 1) throw failures[0]\n if (failures.length > 1) {\n throw new AggregateError(failures, `${event.name}: ${failures.length} subscribers failed`)\n }\n }\n}\n","import { classifyFailure, createLogger, type DomainEvent } from '@/core'\n\nimport type {\n DeadLetterReason,\n IOutboxRepository,\n OutboxClaim,\n OutboxMessage,\n} from './outbox-repository.ts'\n\n/**\n * Delivers committed outbox rows. The unit of work only wakes it after a commit that wrote\n * events; the app decides what a wake is: `waitUntil`, a Durable Object alarm, nothing.\n * The sweep is the source of truth, so a lost wake loses no events.\n */\nexport interface IOutboxRelay {\n wake(): void\n}\n\n/** The publisher sends valid events and returns oversized events. QueueClient satisfies this port directly. */\nexport interface IOutboxPublisher {\n /** Resolves after queue acceptance; a rejected batch may already have sent some events. */\n sendBatchPartial(messages: ReadonlyArray<DomainEvent>): Promise<ReadonlyArray<DomainEvent>>\n}\n\n/** Optional limits for one relay; durable attempts exclude the publisher's immediate retries. */\nexport type OutboxRelayOptions = {\n readonly batchSize?: number\n readonly maxBatches?: number\n readonly maxAttempts?: number\n}\n\nconst logger = createLogger('outbox-relay')\n\n/**\n * Publishes committed events with bounded durable retries. Ported from Typist's relay with per-row exhaustion.\n * Send failures are recorded; repository failures propagate to the wake or scheduled boundary.\n */\nexport class OutboxRelay {\n private readonly options: Required<OutboxRelayOptions>\n\n constructor(\n private readonly publisher: IOutboxPublisher,\n private readonly repository: IOutboxRepository,\n options: OutboxRelayOptions = {},\n ) {\n this.options = {\n batchSize: options.batchSize ?? 50,\n maxBatches: options.maxBatches ?? 10,\n maxAttempts: options.maxAttempts ?? 10,\n }\n }\n\n /** Return the claimed count, including released and dead-lettered rows. Zero means no due rows. */\n async flush(): Promise<number> {\n const claim = await this.repository.claimPending(this.options.batchSize)\n const exhausted = claim.messages.filter((row) => row.attempts > this.options.maxAttempts)\n await this.deadLetter(claim, exhausted, 'exhausted', 'Claim attempt limit exceeded')\n const pending = claim.messages.filter((row) => row.attempts <= this.options.maxAttempts)\n if (pending.length === 0) return claim.messages.length\n\n let oversized: ReadonlyArray<DomainEvent>\n try {\n oversized = await this.publisher.sendBatchPartial(pending.map((row) => row.payload))\n } catch (error) {\n await this.handleSendFailure(claim, pending, error)\n return claim.messages.length\n }\n\n const oversizedIds = new Set(oversized.map((event) => event.id))\n await this.deadLetter(\n claim,\n pending.filter((row) => oversizedIds.has(row.payload.id)),\n 'oversized',\n 'Exceeds queue message size limit',\n )\n const sent = pending.filter((row) => !oversizedIds.has(row.payload.id))\n if (sent.length > 0) await this.repository.markPublished(claim, sent)\n return claim.messages.length\n }\n\n /** Drain until an empty claim or the batch limit; return the total claimed count. */\n async drain(): Promise<number> {\n let total = 0\n for (let batch = 0; batch < this.options.maxBatches; batch += 1) {\n // oxlint-disable-next-line no-await-in-loop -- An empty claim must stop the drain before another claim starts.\n const claimed = await this.flush()\n total += claimed\n if (claimed === 0) break\n }\n return total\n }\n\n private async handleSendFailure(\n claim: OutboxClaim,\n pending: ReadonlyArray<OutboxMessage>,\n cause: unknown,\n ): Promise<void> {\n const classification = classifyFailure(cause)\n const message =\n cause instanceof Error ? cause.message : 'Publisher rejected with a non-error value'\n if (classification === 'terminal') {\n await this.deadLetter(claim, pending, 'terminal', message, cause)\n return\n }\n const exhausted = pending.filter((row) => row.attempts >= this.options.maxAttempts)\n await this.deadLetter(claim, exhausted, 'exhausted', message, cause)\n const retry = pending.filter((row) => row.attempts < this.options.maxAttempts)\n if (retry.length > 0) await this.repository.releaseClaim(claim, retry)\n }\n\n private async deadLetter(\n claim: OutboxClaim,\n messages: ReadonlyArray<OutboxMessage>,\n reason: DeadLetterReason,\n errorMessage: string,\n cause?: unknown,\n ): Promise<void> {\n if (messages.length === 0) return\n await this.repository.deadLetter(claim, messages, reason, errorMessage)\n logger.error('outbox_messages_dead_lettered', {\n error: cause,\n claimToken: claim.token,\n count: messages.length,\n reason,\n })\n }\n}\n","import type { IOutboxRetentionRepository } from './outbox-repository.ts'\n\n/** Typist's retention defaults; receipt retention must cover the application's supported replay period. */\nexport type OutboxRetentionOptions = {\n readonly publishedRetentionMs?: number\n readonly failedRetentionMs?: number\n readonly receiptRetentionMs?: number\n readonly batchSize?: number\n readonly maxBatches?: number\n}\n\n/** Deleted records per category during one bounded sweep. */\nexport type OutboxRetentionResult = {\n readonly published: number\n readonly failed: number\n readonly receipts: number\n}\n\n/** Bounded cleanup ported from Typist's message retention handler. The application schedules each sweep. */\nexport class OutboxRetention {\n private readonly options: Required<OutboxRetentionOptions>\n\n constructor(\n private readonly repository: IOutboxRetentionRepository,\n options: OutboxRetentionOptions = {},\n ) {\n this.options = {\n publishedRetentionMs: options.publishedRetentionMs ?? 86_400_000,\n failedRetentionMs: options.failedRetentionMs ?? 30 * 86_400_000,\n receiptRetentionMs: options.receiptRetentionMs ?? 7 * 86_400_000,\n batchSize: options.batchSize ?? 1_000,\n maxBatches: options.maxBatches ?? 10,\n }\n }\n\n /** Calculate all cutoffs from one supplied time; propagate repository failures to the calling boundary. */\n async sweep(at: Date): Promise<OutboxRetentionResult> {\n const { publishedRetentionMs, failedRetentionMs, receiptRetentionMs } = this.options\n const published = await this.deleteBatches((size) =>\n this.repository.deletePublishedBefore(new Date(at.getTime() - publishedRetentionMs), size),\n )\n const failed = await this.deleteBatches((size) =>\n this.repository.deleteFailedBefore(new Date(at.getTime() - failedRetentionMs), size),\n )\n const receipts = await this.deleteBatches((size) =>\n this.repository.deleteReceiptsBefore(new Date(at.getTime() - receiptRetentionMs), size),\n )\n return { published, failed, receipts }\n }\n\n private async deleteBatches(deleteBatch: (size: number) => Promise<number>): Promise<number> {\n let total = 0\n for (let batch = 0; batch < this.options.maxBatches; batch += 1) {\n // oxlint-disable-next-line no-await-in-loop -- The delete count decides whether another batch is needed.\n const deleted = await deleteBatch(this.options.batchSize)\n total += deleted\n if (deleted < this.options.batchSize) break\n }\n return total\n }\n}\n","import type { MessageId } from '@/core'\n\nimport type { IEventCollector } from './event-collector.ts'\nimport { MessageAlreadyProcessedError } from './message-already-processed.error.ts'\nimport type { IMessageStore } from './message-store.ts'\nimport type { IOutboxRelay } from './outbox-relay.ts'\nimport type { ITransaction } from './transaction.ts'\n\n/**\n * Commits the processed-message record, repository writes, and emitted events together before resolving.\n * An existing record rejects with `MessageAlreadyProcessedError`; external effects must run through the outbox after commit.\n * Nested calls reject with `UnitOfWorkAlreadyActiveError`; independent concurrent calls are allowed.\n */\nexport interface IUnitOfWork<Repos> {\n run<T>(messageId: MessageId, work: (repos: Repos) => Promise<T>): Promise<T>\n}\n\nexport type UnitOfWorkDeps<Tx, Repos> = {\n readonly transaction: ITransaction<Tx>\n /** The processed-messages and outbox repository for this transaction. */\n readonly messageStore: (tx: Tx) => IMessageStore\n /** Repositories participate in the current transaction. */\n readonly createRepositories: (tx: Tx) => Repos\n readonly eventCollector: IEventCollector\n readonly relay: IOutboxRelay\n}\n\n/** The unit of work: the boundary, the repositories, the events. Every database call is a port. */\nexport class UnitOfWork<Tx, Repos> implements IUnitOfWork<Repos> {\n constructor(private readonly deps: UnitOfWorkDeps<Tx, Repos>) {}\n\n async run<T>(messageId: MessageId, work: (repos: Repos) => Promise<T>): Promise<T> {\n const { transaction, messageStore, createRepositories, eventCollector, relay } = this.deps\n let written = 0\n\n let value: T\n try {\n value = await transaction.transaction(async (tx) => {\n const store = messageStore(tx)\n await store.claim(messageId)\n const repos = createRepositories(tx)\n const result = await work(repos)\n const pending = eventCollector.collect()\n await store.persistEvents(pending)\n written = pending.length\n return result\n })\n } catch (error) {\n throw MessageAlreadyProcessedError.from(error, messageId) ?? error\n }\n\n if (written > 0) relay.wake()\n return value\n }\n}\n","import { AppError } from '@/core'\n\n/** A boundary was opened inside another. One message, one atomic scope. */\nexport class UnitOfWorkAlreadyActiveError extends AppError {\n readonly _tag = 'UnitOfWorkAlreadyActiveError'\n readonly classification = 'terminal'\n\n constructor() {\n super('a unit of work is already active')\n }\n}\n"],"mappings":"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"}
@@ -1 +0,0 @@
1
- {"version":3,"file":"slack-webhook-j1FCPeu8.js","names":[],"sources":["../src/infrastructure/slack/ci-message.ts","../src/infrastructure/slack/slack-webhook.ts"],"sourcesContent":["import { z } from 'zod'\n\n/** The result of one CI job or step, in GitHub Actions terms. */\nexport type CiOutcome = 'success' | 'failure' | 'cancelled' | 'skipped'\n\n/** What the run delivers after its checks pass. */\nexport type CiTarget =\n | { readonly kind: 'check' }\n | {\n readonly kind: 'deploy'\n readonly accountId: string\n readonly worker: string\n readonly siteUrl: string\n /** Absent when the run reached no deploy, such as a pull request. */\n readonly versionId: string | undefined\n }\n | {\n readonly kind: 'publish'\n readonly packageName: string\n /** Absent when npm already has the package version. */\n readonly version: string | undefined\n }\n\n/** One CI run, parsed from the environment that the workflow sets. */\nexport interface CiRun {\n readonly repository: string\n readonly commitSha: string\n readonly ref:\n | { readonly kind: 'branch'; readonly name: string }\n | {\n readonly kind: 'pullRequest'\n readonly number: number\n readonly url: string\n readonly title: string\n }\n readonly runUrl: string\n readonly jobs: ReadonlyArray<CiStepResult>\n readonly steps: ReadonlyArray<CiStepResult>\n readonly target: CiTarget\n}\n\n/** A named job or step and its outcome. */\nexport interface CiStepResult {\n readonly name: string\n readonly outcome: CiOutcome\n}\n\n/** The commit facts that only git knows. */\nexport interface CiCommit {\n readonly subject: string\n readonly author: string\n}\n\n/** A Slack incoming-webhook payload. */\nexport interface SlackMessage {\n readonly text: string\n readonly blocks: ReadonlyArray<SlackBlock>\n}\n\ntype SlackElement =\n | { readonly type: 'mrkdwn'; readonly text: string }\n | { readonly type: 'image'; readonly image_url: string; readonly alt_text: string }\n\ntype SlackBlock =\n | { readonly type: 'section'; readonly text: { readonly type: 'mrkdwn'; readonly text: string } }\n | { readonly type: 'context'; readonly elements: ReadonlyArray<SlackElement> }\n\n/** The CI environment could not be parsed. `issues` names each invalid variable. */\nexport class CiRunParseError extends Error {\n readonly _tag = 'CiRunParseError' as const\n\n constructor(\n readonly issues: ReadonlyArray<string>,\n override readonly cause: unknown,\n ) {\n super(`Invalid CI environment: ${issues.join(', ')}`)\n }\n}\n\nconst OutcomeSchema = z.enum(['success', 'failure', 'cancelled', 'skipped'])\n\nconst jsonRecord = <T extends z.ZodType>(value: T) =>\n z\n .string()\n .transform((text, context) => {\n try {\n const value: unknown = JSON.parse(text)\n return value\n } catch {\n context.addIssue({ code: 'custom', message: 'Invalid JSON' })\n return z.NEVER\n }\n })\n .pipe(z.record(z.string(), value))\n\nconst CiEnvSchema = z.object({\n REPO: z.string().regex(/^[\\w.-]+\\/[\\w.-]+$/),\n COMMIT_SHA: z.string().regex(/^[0-9a-f]{40}$/),\n BRANCH: z.string().min(1),\n RUN_URL: z.httpUrl(),\n JOBS_JSON: jsonRecord(z.object({ result: OutcomeSchema })).optional(),\n STEPS_JSON: jsonRecord(z.object({ outcome: OutcomeSchema })).optional(),\n PR_NUMBER: z.coerce.number().int().positive().optional(),\n PR_URL: z.httpUrl().optional(),\n PR_TITLE: z.string().min(1).optional(),\n ACTION: z.enum(['check', 'deploy', 'publish']).default('check'),\n CLOUDFLARE_ACCOUNT_ID: z.string().min(1).optional(),\n CLOUDFLARE_WORKER: z.string().min(1).optional(),\n SITE_URL: z.httpUrl().optional(),\n DEPLOY_VERSION_ID: z.string().min(1).optional(),\n NPM_PACKAGE: z.string().min(1).optional(),\n NPM_VERSION: z.string().min(1).optional(),\n})\n\ntype CiEnv = z.infer<typeof CiEnvSchema>\n\n/**\n * Parse the variables that a workflow passes to the notifier. GitHub expressions render a\n * missing value as an empty string, so an empty variable counts as unset.\n * @throws {CiRunParseError} when a variable is missing or invalid.\n */\nexport function parseCiRun(env: Readonly<Record<string, string | undefined>>): CiRun {\n const present = Object.fromEntries(Object.entries(env).filter(([, value]) => value !== ''))\n const parsed = CiEnvSchema.safeParse(present)\n if (!parsed.success) {\n const issues = parsed.error.issues.map((issue) => issue.path.join('.'))\n throw new CiRunParseError(issues, parsed.error)\n }\n return toCiRun(parsed.data)\n}\n\nfunction toCiRun(env: CiEnv): CiRun {\n const ref: CiRun['ref'] =\n env.PR_NUMBER !== undefined && env.PR_URL !== undefined && env.PR_TITLE !== undefined\n ? { kind: 'pullRequest', number: env.PR_NUMBER, url: env.PR_URL, title: env.PR_TITLE }\n : { kind: 'branch', name: env.BRANCH }\n return {\n repository: env.REPO,\n commitSha: env.COMMIT_SHA,\n ref,\n runUrl: env.RUN_URL,\n jobs: Object.entries(env.JOBS_JSON ?? {}).map(([id, job]) => ({\n name: displayName(id),\n outcome: job.result,\n })),\n steps: Object.entries(env.STEPS_JSON ?? {}).map(([id, step]) => ({\n name: displayName(id),\n outcome: step.outcome,\n })),\n target: toTarget(env),\n }\n}\n\nfunction toTarget(env: CiEnv): CiTarget {\n switch (env.ACTION) {\n case 'check':\n return { kind: 'check' }\n case 'deploy': {\n const { CLOUDFLARE_ACCOUNT_ID: accountId, CLOUDFLARE_WORKER: worker, SITE_URL: siteUrl } = env\n if (accountId === undefined || worker === undefined || siteUrl === undefined) {\n throw new CiRunParseError(\n ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_WORKER', 'SITE_URL'],\n undefined,\n )\n }\n return { kind: 'deploy', accountId, worker, siteUrl, versionId: env.DEPLOY_VERSION_ID }\n }\n case 'publish':\n if (env.NPM_PACKAGE === undefined) throw new CiRunParseError(['NPM_PACKAGE'], undefined)\n return { kind: 'publish', packageName: env.NPM_PACKAGE, version: env.NPM_VERSION }\n }\n}\n\n// A job or step id such as `check-and-build` reads as \"Check and build\".\nfunction displayName(id: string): string {\n const words = id.replaceAll(/[-_]/g, ' ')\n return words.charAt(0).toUpperCase() + words.slice(1)\n}\n\nconst logos = {\n depot: 'https://avatars.githubusercontent.com/u/68166601?s=64',\n github: 'https://avatars.githubusercontent.com/u/9919?s=64',\n cloudflare: 'https://avatars.githubusercontent.com/u/314135?s=64',\n npm: 'https://avatars.githubusercontent.com/u/6078720?s=64',\n} as const\n\n/** Build the four-line CI message: result, change, links, and identity. */\nexport function buildCiMessage(run: CiRun, commit: CiCommit): SlackMessage {\n const headline = headlineFor(run)\n const change = run.ref.kind === 'pullRequest' ? run.ref.title : commit.subject\n return {\n text: `${headline} — ${change}`,\n blocks: [\n { type: 'section', text: { type: 'mrkdwn', text: `*${headline}*\\n${escape(change)}` } },\n { type: 'context', elements: linkElements(run) },\n { type: 'context', elements: [{ type: 'mrkdwn', text: identityLine(run, commit) }] },\n ],\n }\n}\n\nfunction headlineFor(run: CiRun): string {\n const failedJobs = namesWith(run.jobs, 'failure')\n if (failedJobs) return `:x: CI failed: ${failedJobs}`\n const cancelledJobs = namesWith(run.jobs, 'cancelled')\n if (cancelledJobs) return `:white_circle: CI cancelled: ${cancelledJobs}`\n const failedSteps = namesWith(run.steps, 'failure')\n const { target } = run\n switch (target.kind) {\n case 'check':\n return failedSteps ? `:x: CI failed: ${failedSteps}` : ':white_check_mark: CI passed'\n case 'deploy':\n if (failedSteps) return `:x: Deploy failed: ${failedSteps}`\n return target.versionId === undefined\n ? ':white_check_mark: CI passed'\n : ':white_check_mark: Deployed to production'\n case 'publish':\n if (failedSteps) return `:x: Publish failed: ${failedSteps}`\n return target.version === undefined\n ? ':white_check_mark: CI passed, nothing to publish'\n : `:white_check_mark: Published ${target.packageName} ${target.version}`\n }\n}\n\nfunction namesWith(results: ReadonlyArray<CiStepResult>, outcome: CiOutcome): string {\n return results\n .filter((result) => result.outcome === outcome)\n .map((result) => result.name)\n .join(', ')\n}\n\nfunction linkElements(run: CiRun): ReadonlyArray<SlackElement> {\n const runHost = new URL(run.runUrl).hostname\n const runLogo = runHost.endsWith('github.com') ? logos.github : logos.depot\n const elements: SlackElement[] = [\n { type: 'image', image_url: runLogo, alt_text: runHost },\n { type: 'mrkdwn', text: `<${run.runUrl}|CI run>` },\n ]\n const { target } = run\n if (target.kind === 'deploy' && target.versionId !== undefined) {\n const dashboard = `https://dash.cloudflare.com/${target.accountId}/workers/services/view/${target.worker}/production/deployments`\n const site = new URL(target.siteUrl)\n elements.push(\n { type: 'mrkdwn', text: '·' },\n { type: 'image', image_url: logos.cloudflare, alt_text: 'Cloudflare' },\n { type: 'mrkdwn', text: `<${dashboard}|Version \\`${target.versionId.slice(0, 8)}\\`>` },\n { type: 'mrkdwn', text: `· :globe_with_meridians: <${site.origin}|${site.host}>` },\n )\n }\n if (target.kind === 'publish' && target.version !== undefined) {\n const npm = `https://www.npmjs.com/package/${target.packageName}/v/${target.version}`\n elements.push(\n { type: 'mrkdwn', text: '·' },\n { type: 'image', image_url: logos.npm, alt_text: 'npm' },\n { type: 'mrkdwn', text: `<${npm}|${target.packageName}@${target.version}>` },\n )\n }\n return elements\n}\n\nfunction identityLine(run: CiRun, commit: CiCommit): string {\n const repository = run.repository.split('/').at(-1) ?? run.repository\n const ref =\n run.ref.kind === 'pullRequest' ? `<${run.ref.url}|PR #${run.ref.number}>` : run.ref.name\n const commitUrl = `https://github.com/${run.repository}/commit/${run.commitSha}`\n return [\n repository,\n ref,\n `<${commitUrl}|\\`${run.commitSha.slice(0, 7)}\\`>`,\n escape(commit.author),\n ].join(' · ')\n}\n\n// Slack mrkdwn treats these three characters as markup.\nfunction escape(text: string): string {\n return text.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;')\n}\n","import type { SlackMessage } from './ci-message.ts'\n\n/** Slack refused the message or could not be reached. */\nexport class SlackWebhookError extends Error {\n readonly _tag = 'SlackWebhookError' as const\n\n constructor(\n readonly status: number | undefined,\n override readonly cause: unknown,\n ) {\n super(status === undefined ? 'Slack webhook unreachable' : `Slack webhook returned ${status}`)\n }\n}\n\n/**\n * Post one message to a Slack incoming webhook.\n * @throws {SlackWebhookError} when the request fails or Slack answers with an error status.\n */\nexport async function postSlackMessage(\n webhookUrl: string,\n message: SlackMessage,\n fetcher: typeof fetch = fetch,\n): Promise<void> {\n let response: Response\n try {\n response = await fetcher(webhookUrl, {\n method: 'POST',\n headers: { 'Content-Type': 'application/json' },\n body: JSON.stringify(message),\n signal: AbortSignal.timeout(30_000),\n })\n } catch (cause) {\n throw new SlackWebhookError(undefined, cause)\n }\n if (!response.ok) throw new SlackWebhookError(response.status, await response.text())\n}\n"],"mappings":";;;;AAoEA,IAAa,kBAAb,cAAqC,MAAM;CAI9B;CACS;CAJpB,AAAS,OAAO;CAEhB,YACE,AAAS,QACT,AAAkB,OAClB;EACA,MAAM,2BAA2B,OAAO,KAAK,IAAI,GAAG;EAH3C;EACS;CAGpB;AACF;AAEA,MAAM,gBAAgB,EAAE,KAAK;CAAC;CAAW;CAAW;CAAa;AAAS,CAAC;AAE3E,MAAM,cAAmC,UACvC,EACG,OAAO,CAAC,CACR,WAAW,MAAM,YAAY;CAC5B,IAAI;EAEF,OADuB,KAAK,MAAM,IACvB;CACb,QAAQ;EACN,QAAQ,SAAS;GAAE,MAAM;GAAU,SAAS;EAAe,CAAC;EAC5D,OAAO,EAAE;CACX;AACF,CAAC,CAAC,CACD,KAAK,EAAE,OAAO,EAAE,OAAO,GAAG,KAAK,CAAC;AAErC,MAAM,cAAc,EAAE,OAAO;CAC3B,MAAM,EAAE,OAAO,CAAC,CAAC,MAAM,oBAAoB;CAC3C,YAAY,EAAE,OAAO,CAAC,CAAC,MAAM,gBAAgB;CAC7C,QAAQ,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC;CACxB,SAAS,EAAE,QAAQ;CACnB,WAAW,WAAW,EAAE,OAAO,EAAE,QAAQ,cAAc,CAAC,CAAC,CAAC,CAAC,SAAS;CACpE,YAAY,WAAW,EAAE,OAAO,EAAE,SAAS,cAAc,CAAC,CAAC,CAAC,CAAC,SAAS;CACtE,WAAW,EAAE,OAAO,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,CAAC,SAAS;CACvD,QAAQ,EAAE,QAAQ,CAAC,CAAC,SAAS;CAC7B,UAAU,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;CACrC,QAAQ,EAAE,KAAK;EAAC;EAAS;EAAU;CAAS,CAAC,CAAC,CAAC,QAAQ,OAAO;CAC9D,uBAAuB,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;CAClD,mBAAmB,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;CAC9C,UAAU,EAAE,QAAQ,CAAC,CAAC,SAAS;CAC/B,mBAAmB,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;CAC9C,aAAa,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;CACxC,aAAa,EAAE,OAAO,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,SAAS;AAC1C,CAAC;;;;;;AASD,SAAgB,WAAW,KAA0D;CACnF,MAAM,UAAU,OAAO,YAAY,OAAO,QAAQ,GAAG,CAAC,CAAC,QAAQ,GAAG,WAAW,UAAU,EAAE,CAAC;CAC1F,MAAM,SAAS,YAAY,UAAU,OAAO;CAC5C,IAAI,CAAC,OAAO,SAAS;EACnB,MAAM,SAAS,OAAO,MAAM,OAAO,KAAK,UAAU,MAAM,KAAK,KAAK,GAAG,CAAC;EACtE,MAAM,IAAI,gBAAgB,QAAQ,OAAO,KAAK;CAChD;CACA,OAAO,QAAQ,OAAO,IAAI;AAC5B;AAEA,SAAS,QAAQ,KAAmB;CAClC,MAAM,MACJ,IAAI,cAAc,UAAa,IAAI,WAAW,UAAa,IAAI,aAAa,SACxE;EAAE,MAAM;EAAe,QAAQ,IAAI;EAAW,KAAK,IAAI;EAAQ,OAAO,IAAI;CAAS,IACnF;EAAE,MAAM;EAAU,MAAM,IAAI;CAAO;CACzC,OAAO;EACL,YAAY,IAAI;EAChB,WAAW,IAAI;EACf;EACA,QAAQ,IAAI;EACZ,MAAM,OAAO,QAAQ,IAAI,aAAa,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,UAAU;GAC5D,MAAM,YAAY,EAAE;GACpB,SAAS,IAAI;EACf,EAAE;EACF,OAAO,OAAO,QAAQ,IAAI,cAAc,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,IAAI,WAAW;GAC/D,MAAM,YAAY,EAAE;GACpB,SAAS,KAAK;EAChB,EAAE;EACF,QAAQ,SAAS,GAAG;CACtB;AACF;AAEA,SAAS,SAAS,KAAsB;CACtC,QAAQ,IAAI,QAAZ;EACE,KAAK,SACH,OAAO,EAAE,MAAM,QAAQ;EACzB,KAAK,UAAU;GACb,MAAM,EAAE,uBAAuB,WAAW,mBAAmB,QAAQ,UAAU,YAAY;GAC3F,IAAI,cAAc,UAAa,WAAW,UAAa,YAAY,QACjE,MAAM,IAAI,gBACR;IAAC;IAAyB;IAAqB;GAAU,GACzD,MACF;GAEF,OAAO;IAAE,MAAM;IAAU;IAAW;IAAQ;IAAS,WAAW,IAAI;GAAkB;EACxF;EACA,KAAK;GACH,IAAI,IAAI,gBAAgB,QAAW,MAAM,IAAI,gBAAgB,CAAC,aAAa,GAAG,MAAS;GACvF,OAAO;IAAE,MAAM;IAAW,aAAa,IAAI;IAAa,SAAS,IAAI;GAAY;CACrF;AACF;AAGA,SAAS,YAAY,IAAoB;CACvC,MAAM,QAAQ,GAAG,WAAW,SAAS,GAAG;CACxC,OAAO,MAAM,OAAO,CAAC,CAAC,CAAC,YAAY,IAAI,MAAM,MAAM,CAAC;AACtD;AAEA,MAAM,QAAQ;CACZ,OAAO;CACP,QAAQ;CACR,YAAY;CACZ,KAAK;AACP;;AAGA,SAAgB,eAAe,KAAY,QAAgC;CACzE,MAAM,WAAW,YAAY,GAAG;CAChC,MAAM,SAAS,IAAI,IAAI,SAAS,gBAAgB,IAAI,IAAI,QAAQ,OAAO;CACvE,OAAO;EACL,MAAM,GAAG,SAAS,KAAK;EACvB,QAAQ;GACN;IAAE,MAAM;IAAW,MAAM;KAAE,MAAM;KAAU,MAAM,IAAI,SAAS,KAAK,OAAO,MAAM;IAAI;GAAE;GACtF;IAAE,MAAM;IAAW,UAAU,aAAa,GAAG;GAAE;GAC/C;IAAE,MAAM;IAAW,UAAU,CAAC;KAAE,MAAM;KAAU,MAAM,aAAa,KAAK,MAAM;IAAE,CAAC;GAAE;EACrF;CACF;AACF;AAEA,SAAS,YAAY,KAAoB;CACvC,MAAM,aAAa,UAAU,IAAI,MAAM,SAAS;CAChD,IAAI,YAAY,OAAO,kBAAkB;CACzC,MAAM,gBAAgB,UAAU,IAAI,MAAM,WAAW;CACrD,IAAI,eAAe,OAAO,gCAAgC;CAC1D,MAAM,cAAc,UAAU,IAAI,OAAO,SAAS;CAClD,MAAM,EAAE,WAAW;CACnB,QAAQ,OAAO,MAAf;EACE,KAAK,SACH,OAAO,cAAc,kBAAkB,gBAAgB;EACzD,KAAK;GACH,IAAI,aAAa,OAAO,sBAAsB;GAC9C,OAAO,OAAO,cAAc,SACxB,iCACA;EACN,KAAK;GACH,IAAI,aAAa,OAAO,uBAAuB;GAC/C,OAAO,OAAO,YAAY,SACtB,qDACA,gCAAgC,OAAO,YAAY,GAAG,OAAO;CACrE;AACF;AAEA,SAAS,UAAU,SAAsC,SAA4B;CACnF,OAAO,QACJ,QAAQ,WAAW,OAAO,YAAY,OAAO,CAAC,CAC9C,KAAK,WAAW,OAAO,IAAI,CAAC,CAC5B,KAAK,IAAI;AACd;AAEA,SAAS,aAAa,KAAyC;CAC7D,MAAM,UAAU,IAAI,IAAI,IAAI,MAAM,CAAC,CAAC;CAEpC,MAAM,WAA2B,CAC/B;EAAE,MAAM;EAAS,WAFH,QAAQ,SAAS,YAAY,IAAI,MAAM,SAAS,MAAM;EAE/B,UAAU;CAAQ,GACvD;EAAE,MAAM;EAAU,MAAM,IAAI,IAAI,OAAO;CAAU,CACnD;CACA,MAAM,EAAE,WAAW;CACnB,IAAI,OAAO,SAAS,YAAY,OAAO,cAAc,QAAW;EAC9D,MAAM,YAAY,+BAA+B,OAAO,UAAU,yBAAyB,OAAO,OAAO;EACzG,MAAM,OAAO,IAAI,IAAI,OAAO,OAAO;EACnC,SAAS,KACP;GAAE,MAAM;GAAU,MAAM;EAAI,GAC5B;GAAE,MAAM;GAAS,WAAW,MAAM;GAAY,UAAU;EAAa,GACrE;GAAE,MAAM;GAAU,MAAM,IAAI,UAAU,aAAa,OAAO,UAAU,MAAM,GAAG,CAAC,EAAE;EAAK,GACrF;GAAE,MAAM;GAAU,MAAM,8BAA8B,KAAK,OAAO,GAAG,KAAK,KAAK;EAAG,CACpF;CACF;CACA,IAAI,OAAO,SAAS,aAAa,OAAO,YAAY,QAAW;EAC7D,MAAM,MAAM,iCAAiC,OAAO,YAAY,KAAK,OAAO;EAC5E,SAAS,KACP;GAAE,MAAM;GAAU,MAAM;EAAI,GAC5B;GAAE,MAAM;GAAS,WAAW,MAAM;GAAK,UAAU;EAAM,GACvD;GAAE,MAAM;GAAU,MAAM,IAAI,IAAI,GAAG,OAAO,YAAY,GAAG,OAAO,QAAQ;EAAG,CAC7E;CACF;CACA,OAAO;AACT;AAEA,SAAS,aAAa,KAAY,QAA0B;CAK1D,OAAO;EAJY,IAAI,WAAW,MAAM,GAAG,CAAC,CAAC,GAAG,EAAE,KAAK,IAAI;EAEzD,IAAI,IAAI,SAAS,gBAAgB,IAAI,IAAI,IAAI,IAAI,OAAO,IAAI,IAAI,OAAO,KAAK,IAAI,IAAI;EAKpF,IAAI,sBAJkC,IAAI,WAAW,UAAU,IAAI,YAIrD,KAAK,IAAI,UAAU,MAAM,GAAG,CAAC,EAAE;EAC7C,OAAO,OAAO,MAAM;CACtB,CAAC,CAAC,KAAK,OAAO;AAChB;AAGA,SAAS,OAAO,MAAsB;CACpC,OAAO,KAAK,WAAW,KAAK,OAAO,CAAC,CAAC,WAAW,KAAK,MAAM,CAAC,CAAC,WAAW,KAAK,MAAM;AACrF;;;;;AChRA,IAAa,oBAAb,cAAuC,MAAM;CAIhC;CACS;CAJpB,AAAS,OAAO;CAEhB,YACE,AAAS,QACT,AAAkB,OAClB;EACA,MAAM,WAAW,SAAY,8BAA8B,0BAA0B,QAAQ;EAHpF;EACS;CAGpB;AACF;;;;;AAMA,eAAsB,iBACpB,YACA,SACA,UAAwB,OACT;CACf,IAAI;CACJ,IAAI;EACF,WAAW,MAAM,QAAQ,YAAY;GACnC,QAAQ;GACR,SAAS,EAAE,gBAAgB,mBAAmB;GAC9C,MAAM,KAAK,UAAU,OAAO;GAC5B,QAAQ,YAAY,QAAQ,GAAM;EACpC,CAAC;CACH,SAAS,OAAO;EACd,MAAM,IAAI,kBAAkB,QAAW,KAAK;CAC9C;CACA,IAAI,CAAC,SAAS,IAAI,MAAM,IAAI,kBAAkB,SAAS,QAAQ,MAAM,SAAS,KAAK,CAAC;AACtF"}