@andypai/agent-kanban 0.6.3 → 0.6.5

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 (45) hide show
  1. package/package.json +11 -3
  2. package/src/__tests__/index.test.ts +27 -1
  3. package/src/__tests__/jira-cache.test.ts +68 -0
  4. package/src/__tests__/jira-provider-read.test.ts +130 -0
  5. package/src/__tests__/jira-wiring.test.ts +41 -0
  6. package/src/__tests__/linear-provider-comment.test.ts +81 -0
  7. package/src/__tests__/linear-provider-sync.test.ts +107 -0
  8. package/src/__tests__/linear-state-resolution.test.ts +60 -0
  9. package/src/__tests__/local-provider-core.test.ts +245 -0
  10. package/src/__tests__/postgres-jira-provider.test.ts +55 -0
  11. package/src/__tests__/postgres-linear-provider.test.ts +90 -0
  12. package/src/__tests__/postgres-local-provider.test.ts +26 -0
  13. package/src/__tests__/provider-sync-core.test.ts +150 -0
  14. package/src/__tests__/provider-team-info.test.ts +37 -0
  15. package/src/__tests__/ui-board-slice.test.ts +14 -0
  16. package/src/__tests__/use-cases.test.ts +21 -42
  17. package/src/api.ts +78 -132
  18. package/src/column-roles.ts +3 -2
  19. package/src/index.ts +135 -92
  20. package/src/mcp/core.ts +7 -8
  21. package/src/provider-runtime.ts +12 -56
  22. package/src/providers/cache-task-mappers.ts +123 -0
  23. package/src/providers/factory.ts +103 -0
  24. package/src/providers/index.ts +4 -25
  25. package/src/providers/jira-cache.ts +41 -99
  26. package/src/providers/jira-core.ts +81 -81
  27. package/src/providers/linear-cache.ts +42 -69
  28. package/src/providers/linear-core.ts +44 -56
  29. package/src/providers/local-core.ts +226 -0
  30. package/src/providers/local.ts +6 -182
  31. package/src/providers/postgres-batch.ts +10 -0
  32. package/src/providers/postgres-jira-cache.ts +255 -213
  33. package/src/providers/postgres-jira.ts +2 -20
  34. package/src/providers/postgres-linear-cache.ts +279 -183
  35. package/src/providers/postgres-linear.ts +2 -20
  36. package/src/providers/postgres-local.ts +22 -47
  37. package/src/providers/sqlite-local-store.ts +121 -0
  38. package/src/providers/sync-core.ts +91 -0
  39. package/src/providers/team-info.ts +24 -0
  40. package/src/providers/warn-once.ts +15 -0
  41. package/src/use-cases.ts +7 -131
  42. package/src/webhook-events.ts +29 -1
  43. package/ui/dist/assets/index-DcZH7fI3.js +40 -0
  44. package/ui/dist/index.html +1 -1
  45. package/ui/dist/assets/index-Cigv8a9S.js +0 -40
@@ -1,11 +1,10 @@
1
- import type { Sql, TransactionSql } from 'postgres'
1
+ import type { Sql } from 'postgres'
2
2
 
3
3
  import { ErrorCode, KanbanError } from '../errors'
4
4
  import { generateId } from '../id'
5
5
  import { assembleBoardMetrics, classifyColumnRoles } from '../metrics-spec'
6
6
  import type {
7
7
  ActivityEntry,
8
- BoardBootstrap,
9
8
  BoardConfig,
10
9
  BoardMetrics,
11
10
  BoardView,
@@ -17,20 +16,11 @@ import type {
17
16
  } from '../types'
18
17
  import { POSTGRES_LOCAL_CAPABILITIES } from './capabilities'
19
18
  import { unsupportedOperation } from './errors'
20
- import type {
21
- CreateTaskInput,
22
- KanbanProvider,
23
- ProviderContext,
24
- ProviderSyncStatus,
25
- TaskListFilters,
26
- UpdateTaskInput,
27
- } from './types'
19
+ import { LocalProviderCore, type LocalStorePort } from './local-core'
20
+ import type { CreateTaskInput, TaskListFilters, UpdateTaskInput } from './types'
28
21
  import type { LocalTrackerConfig } from '../tracker-config'
29
22
  import { normalizeLabels, parseStoredLabels } from '../labels'
30
-
31
- // Accepts either the root connection or a scoped transaction handle so the
32
- // mutation helpers can run inside or outside a `sql.begin(...)` block.
33
- type Exec = Sql | TransactionSql
23
+ import type { Exec } from './postgres-batch'
34
24
 
35
25
  const DEFAULT_COLUMNS = [
36
26
  { name: 'recurring', position: 0 },
@@ -98,8 +88,8 @@ function parseMetadata(metadata: string | undefined): string {
98
88
  }
99
89
  }
100
90
 
101
- export class PostgresLocalProvider implements KanbanProvider {
102
- readonly type = 'local' as const
91
+ class PostgresLocalStore implements LocalStorePort {
92
+ readonly capabilities = POSTGRES_LOCAL_CAPABILITIES
103
93
  private readonly ready: Promise<void>
104
94
 
105
95
  constructor(
@@ -294,25 +284,6 @@ export class PostgresLocalProvider implements KanbanProvider {
294
284
  `
295
285
  }
296
286
 
297
- async getContext(): Promise<ProviderContext> {
298
- await this.ready
299
- return { provider: this.type, capabilities: POSTGRES_LOCAL_CAPABILITIES, team: null }
300
- }
301
-
302
- async getBootstrap(): Promise<BoardBootstrap> {
303
- await this.ready
304
- const metrics = await this.getMetrics()
305
- return {
306
- provider: this.type,
307
- capabilities: POSTGRES_LOCAL_CAPABILITIES,
308
- board: await this.getBoard(),
309
- config: await this.getConfig(),
310
- metrics,
311
- activity: await this.getActivity(50),
312
- team: null,
313
- }
314
- }
315
-
316
287
  async getBoard(): Promise<BoardView> {
317
288
  await this.ready
318
289
  const columns = await this.listColumns()
@@ -386,6 +357,11 @@ export class PostgresLocalProvider implements KanbanProvider {
386
357
  return this.enrichTask(row, counts.get(row.id) ?? 0)
387
358
  }
388
359
 
360
+ async getTaskVersion(idOrRef: string): Promise<string> {
361
+ const row = await this.requireTask(idOrRef)
362
+ return String(row.revision ?? 0)
363
+ }
364
+
389
365
  async createTask(input: CreateTaskInput): Promise<Task> {
390
366
  await this.ready
391
367
  const priority = input.priority ?? 'medium'
@@ -418,18 +394,12 @@ export class PostgresLocalProvider implements KanbanProvider {
418
394
  return this.getTask(id)
419
395
  }
420
396
 
421
- async updateTask(idOrRef: string, input: UpdateTaskInput): Promise<Task> {
397
+ async updateTask(
398
+ idOrRef: string,
399
+ input: Omit<UpdateTaskInput, 'expectedVersion'>,
400
+ ): Promise<Task> {
422
401
  await this.ready
423
402
  const current = await this.requireTask(idOrRef)
424
- if (
425
- input.expectedVersion !== undefined &&
426
- String(current.revision ?? 0) !== input.expectedVersion
427
- ) {
428
- throw new KanbanError(
429
- ErrorCode.CONFLICT,
430
- `Task ${idOrRef} was modified since you loaded it (expected version ${input.expectedVersion}, current ${current.revision ?? 0})`,
431
- )
432
- }
433
403
 
434
404
  if (input.priority !== undefined) assertPriority(input.priority)
435
405
  const metadata = input.metadata === undefined ? undefined : parseMetadata(input.metadata)
@@ -763,8 +733,13 @@ export class PostgresLocalProvider implements KanbanProvider {
763
733
  // and fail loudly so the HTTP API and CLI agree.
764
734
  unsupportedOperation('Editing board config is not supported with KANBAN_STORAGE=postgres')
765
735
  }
736
+ }
766
737
 
767
- async getSyncStatus(): Promise<ProviderSyncStatus | null> {
768
- return null
738
+ export class PostgresLocalProvider extends LocalProviderCore {
739
+ constructor(
740
+ sql: Sql,
741
+ config: Pick<LocalTrackerConfig, 'defaultColumns' | 'defaultTaskColumn'> = {},
742
+ ) {
743
+ super(new PostgresLocalStore(sql, config))
769
744
  }
770
745
  }
@@ -0,0 +1,121 @@
1
+ import type { Database } from 'bun:sqlite'
2
+
3
+ import { listActivity } from '../activity'
4
+ import { getConfigPath, loadConfig, saveConfig } from '../config'
5
+ import {
6
+ addComment,
7
+ countComments,
8
+ countCommentsByTask,
9
+ addTask,
10
+ deleteTask,
11
+ getBoardView,
12
+ getComment as getTaskComment,
13
+ getTask,
14
+ listComments as listTaskComments,
15
+ listColumns,
16
+ listTasks,
17
+ moveTask,
18
+ updateComment as updateTaskComment,
19
+ updateTask,
20
+ } from '../db'
21
+ import { getBoardMetrics, getDiscoveredAssignees, getDiscoveredProjects } from '../metrics'
22
+ import type { BoardConfig, BoardMetrics, TaskComment } from '../types'
23
+ import { LOCAL_CAPABILITIES } from './capabilities'
24
+ import type { CreateTaskInput, TaskListFilters, UpdateTaskInput } from './types'
25
+ import type { LocalStorePort } from './local-core'
26
+
27
+ export class SqliteLocalStore implements LocalStorePort {
28
+ readonly capabilities = LOCAL_CAPABILITIES
29
+
30
+ constructor(
31
+ private readonly db: Database,
32
+ private readonly dbPath: string,
33
+ ) {}
34
+
35
+ getBoard() {
36
+ return getBoardView(this.db)
37
+ }
38
+
39
+ listColumns() {
40
+ return listColumns(this.db)
41
+ }
42
+
43
+ listTasks(filters: TaskListFilters = {}) {
44
+ return listTasks(this.db, filters)
45
+ }
46
+
47
+ getTask(idOrRef: string) {
48
+ return getTask(this.db, idOrRef)
49
+ }
50
+
51
+ getTaskVersion(idOrRef: string): string {
52
+ return String(getTask(this.db, idOrRef).revision ?? 0)
53
+ }
54
+
55
+ createTask(input: CreateTaskInput) {
56
+ return addTask(this.db, input.title, input)
57
+ }
58
+
59
+ updateTask(idOrRef: string, input: Omit<UpdateTaskInput, 'expectedVersion'>) {
60
+ return updateTask(this.db, idOrRef, input)
61
+ }
62
+
63
+ moveTask(idOrRef: string, column: string) {
64
+ return moveTask(this.db, idOrRef, column)
65
+ }
66
+
67
+ deleteTask(idOrRef: string) {
68
+ return deleteTask(this.db, idOrRef)
69
+ }
70
+
71
+ listComments(idOrRef: string): TaskComment[] {
72
+ return listTaskComments(this.db, idOrRef)
73
+ }
74
+
75
+ getComment(idOrRef: string, commentId: string): TaskComment {
76
+ return getTaskComment(this.db, idOrRef, commentId)
77
+ }
78
+
79
+ comment(idOrRef: string, body: string): TaskComment {
80
+ return addComment(this.db, idOrRef, body)
81
+ }
82
+
83
+ updateComment(idOrRef: string, commentId: string, body: string): TaskComment {
84
+ return updateTaskComment(this.db, idOrRef, commentId, body)
85
+ }
86
+
87
+ getActivity(limit?: number, taskId?: string) {
88
+ return listActivity(this.db, { limit, taskId })
89
+ }
90
+
91
+ getMetrics() {
92
+ return getBoardMetrics(this.db)
93
+ }
94
+
95
+ getConfig(context: { metrics?: BoardMetrics } = {}): BoardConfig {
96
+ const discoveredAssignees = context.metrics?.assignees ?? getDiscoveredAssignees(this.db)
97
+ const discoveredProjects = context.metrics?.projects ?? getDiscoveredProjects(this.db)
98
+ return {
99
+ ...loadConfig(this.dbPath),
100
+ provider: 'local',
101
+ discoveredAssignees,
102
+ discoveredProjects,
103
+ }
104
+ }
105
+
106
+ patchConfig(input: Partial<BoardConfig>): BoardConfig {
107
+ const config = loadConfig(this.dbPath)
108
+ if (input.members) config.members = input.members
109
+ if (input.projects) config.projects = input.projects
110
+ saveConfig(getConfigPath(this.dbPath), config)
111
+ return this.getConfig()
112
+ }
113
+
114
+ countComments(taskId: string): number {
115
+ return countComments(this.db, taskId)
116
+ }
117
+
118
+ countCommentsByTask(): Map<string, number> {
119
+ return countCommentsByTask(this.db)
120
+ }
121
+ }
@@ -0,0 +1,91 @@
1
+ import type { Task } from '../types'
2
+ import type { ProviderSyncStatus, TaskListFilters } from './types'
3
+
4
+ export function parseSyncTimestamp(value: string | null | undefined): number {
5
+ if (!value) return 0
6
+ const parsed = Date.parse(value)
7
+ return Number.isFinite(parsed) ? parsed : 0
8
+ }
9
+
10
+ export function maxSyncTimestamp(
11
+ a: string | null | undefined,
12
+ b: string | null | undefined,
13
+ ): string | null {
14
+ const aMs = parseSyncTimestamp(a)
15
+ const bMs = parseSyncTimestamp(b)
16
+ if (!aMs && !bMs) return null
17
+ return aMs >= bMs ? (a ?? null) : (b ?? null)
18
+ }
19
+
20
+ export class SyncGate {
21
+ private backgroundManaged = false
22
+
23
+ constructor(private readonly pollingSyncIntervalMs: number) {}
24
+
25
+ shouldSkip(params: {
26
+ force: boolean
27
+ viaWarmer: boolean
28
+ lastSyncAt: string | null | undefined
29
+ now?: number
30
+ }): boolean {
31
+ const lastSyncAtMs = parseSyncTimestamp(params.lastSyncAt)
32
+ if (this.backgroundManaged && !params.force && !params.viaWarmer && lastSyncAtMs > 0) {
33
+ return true
34
+ }
35
+ const now = params.now ?? Date.now()
36
+ return !params.force && lastSyncAtMs > 0 && now - lastSyncAtMs < this.pollingSyncIntervalMs
37
+ }
38
+
39
+ setBackgroundManaged(managed: boolean): void {
40
+ this.backgroundManaged = managed
41
+ }
42
+ }
43
+
44
+ export function syncStatusFromMeta(meta: {
45
+ lastSyncAt: string | null
46
+ lastFullSyncAt: string | null
47
+ lastWebhookAt: string | null
48
+ }): ProviderSyncStatus {
49
+ return {
50
+ lastSyncAt: meta.lastSyncAt,
51
+ lastFullSyncAt: meta.lastFullSyncAt,
52
+ lastWebhookAt: meta.lastWebhookAt,
53
+ }
54
+ }
55
+
56
+ export function applyTaskFilters(tasks: Task[], filters: TaskListFilters = {}): Task[] {
57
+ let result = tasks
58
+ if (filters.priority) result = result.filter((task) => task.priority === filters.priority)
59
+ if (filters.assignee) result = result.filter((task) => task.assignee === filters.assignee)
60
+ if (filters.project) result = result.filter((task) => task.project === filters.project)
61
+ if (filters.sort === 'title') {
62
+ result = [...result].sort((a, b) => a.title.localeCompare(b.title))
63
+ }
64
+ if (filters.sort === 'updated') {
65
+ result = [...result].sort((a, b) => b.updated_at.localeCompare(a.updated_at))
66
+ }
67
+ if (filters.limit) result = result.slice(0, filters.limit)
68
+ return result
69
+ }
70
+
71
+ export async function mapWithConcurrency<T, R>(
72
+ items: T[],
73
+ concurrency: number,
74
+ mapper: (item: T, index: number) => Promise<R>,
75
+ ): Promise<R[]> {
76
+ if (concurrency < 1) throw new RangeError('concurrency must be at least 1')
77
+ const results: R[] = []
78
+ for (let i = 0; i < items.length; i += concurrency) {
79
+ const batch = items.slice(i, i + concurrency)
80
+ results.push(...(await Promise.all(batch.map((item, offset) => mapper(item, i + offset)))))
81
+ }
82
+ return results
83
+ }
84
+
85
+ export async function forEachWithConcurrency<T>(
86
+ items: T[],
87
+ concurrency: number,
88
+ worker: (item: T, index: number) => Promise<void>,
89
+ ): Promise<void> {
90
+ await mapWithConcurrency(items, concurrency, worker)
91
+ }
@@ -0,0 +1,24 @@
1
+ import type { ProviderTeamInfo } from '../types'
2
+
3
+ export function parseProviderTeamInfo(raw: string | null): ProviderTeamInfo | null {
4
+ if (raw === null) return null
5
+ try {
6
+ const parsed = JSON.parse(raw) as unknown
7
+ if (
8
+ parsed &&
9
+ typeof parsed === 'object' &&
10
+ 'id' in parsed &&
11
+ 'key' in parsed &&
12
+ 'name' in parsed &&
13
+ typeof (parsed as { id: unknown }).id === 'string' &&
14
+ typeof (parsed as { key: unknown }).key === 'string' &&
15
+ typeof (parsed as { name: unknown }).name === 'string'
16
+ ) {
17
+ const team = parsed as { id: string; key: string; name: string }
18
+ return { id: team.id, key: team.key, name: team.name }
19
+ }
20
+ } catch {
21
+ // Corrupt persisted provider metadata should not prevent cache bootstrap.
22
+ }
23
+ return null
24
+ }
@@ -0,0 +1,15 @@
1
+ const warnedKeys = new Set<string>()
2
+
3
+ // Emit an informational warning at most once per process for a given key, so
4
+ // repeated hot paths (e.g. inbound webhooks) don't flood the dev console.
5
+ export function warnOnce(key: string, message: string): void {
6
+ if (warnedKeys.has(key)) return
7
+ warnedKeys.add(key)
8
+ console.warn(message)
9
+ }
10
+
11
+ // Test-only: clear the process-wide dedup set so tests can assert warnings
12
+ // independently of which test ran first.
13
+ export function resetWarnOnce(): void {
14
+ warnedKeys.clear()
15
+ }
package/src/use-cases.ts CHANGED
@@ -1,139 +1,15 @@
1
1
  import { normalizeLabels } from './labels'
2
- import type {
3
- CreateTaskInput,
4
- KanbanProvider,
5
- ProviderContext,
6
- TaskListFilters,
7
- UpdateTaskInput,
8
- } from './providers/types'
9
- import type {
10
- ActivityEntry,
11
- BoardBootstrap,
12
- BoardConfig,
13
- BoardMetrics,
14
- BoardView,
15
- Column,
16
- Task,
17
- TaskComment,
18
- } from './types'
2
+ import type { CreateTaskInput } from './providers/types'
19
3
 
20
4
  /**
21
- * Typed use-case layer shared by every transport (CLI, HTTP, MCP).
22
- *
23
- * Each function is the single, typed seam between a transport and the
24
- * provider: transports own their own concerns argument parsing and
25
- * transport-specific error messages, HTTP routing and WebSocket events, MCP
26
- * authorization policy and hooks — then delegate the actual provider call
27
- * here. Keeping that call in one place means cross-cutting concerns (label
28
- * normalization today; future auditing/validation) have a single insertion
29
- * point instead of being re-derived in three transports.
30
- *
31
- * These functions intentionally do NOT perform required-argument validation:
32
- * the transports raise their own (differently worded) errors before calling
33
- * in, so the contract each surface already advertises is preserved.
34
- */
35
-
36
- /**
37
- * Create-task input as a transport supplies it. Identical to CreateTaskInput
38
- * except `labels` is accepted in any raw form (CLI flag arrays, a JSON array,
39
- * a comma-separated string) and normalized here so no transport re-implements
40
- * the normalization.
5
+ * Create-task input as a transport supplies it. It accepts `labels` in raw
6
+ * transport forms (CLI flag arrays, JSON arrays, comma-separated strings), then
7
+ * normalizes them before the provider call. Other provider operations are direct
8
+ * enough for transports to call the provider without a use-case wrapper.
41
9
  */
42
10
  export type CreateTaskCommand = Omit<CreateTaskInput, 'labels'> & { labels?: unknown }
43
11
 
44
- export function createTask(provider: KanbanProvider, input: CreateTaskCommand): Promise<Task> {
12
+ export function normalizeCreateTaskInput(input: CreateTaskCommand): CreateTaskInput {
45
13
  const { labels, ...rest } = input
46
- return provider.createTask({ ...rest, labels: normalizeLabels(labels) })
47
- }
48
-
49
- export function listTasks(provider: KanbanProvider, filters?: TaskListFilters): Promise<Task[]> {
50
- return provider.listTasks(filters)
51
- }
52
-
53
- export function getTask(provider: KanbanProvider, idOrRef: string): Promise<Task> {
54
- return provider.getTask(idOrRef)
55
- }
56
-
57
- export function updateTask(
58
- provider: KanbanProvider,
59
- idOrRef: string,
60
- input: UpdateTaskInput,
61
- ): Promise<Task> {
62
- return provider.updateTask(idOrRef, input)
63
- }
64
-
65
- export function moveTask(provider: KanbanProvider, idOrRef: string, column: string): Promise<Task> {
66
- return provider.moveTask(idOrRef, column)
67
- }
68
-
69
- export function deleteTask(provider: KanbanProvider, idOrRef: string): Promise<Task> {
70
- return provider.deleteTask(idOrRef)
71
- }
72
-
73
- export function listComments(provider: KanbanProvider, idOrRef: string): Promise<TaskComment[]> {
74
- return provider.listComments(idOrRef)
75
- }
76
-
77
- export function getComment(
78
- provider: KanbanProvider,
79
- idOrRef: string,
80
- commentId: string,
81
- ): Promise<TaskComment> {
82
- return provider.getComment(idOrRef, commentId)
83
- }
84
-
85
- export function addComment(
86
- provider: KanbanProvider,
87
- idOrRef: string,
88
- body: string,
89
- ): Promise<TaskComment> {
90
- return provider.comment(idOrRef, body)
91
- }
92
-
93
- export function updateComment(
94
- provider: KanbanProvider,
95
- idOrRef: string,
96
- commentId: string,
97
- body: string,
98
- ): Promise<TaskComment> {
99
- return provider.updateComment(idOrRef, commentId, body)
100
- }
101
-
102
- export function getBoard(provider: KanbanProvider): Promise<BoardView> {
103
- return provider.getBoard()
104
- }
105
-
106
- export function getBootstrap(provider: KanbanProvider): Promise<BoardBootstrap> {
107
- return provider.getBootstrap()
108
- }
109
-
110
- export function getContext(provider: KanbanProvider): Promise<ProviderContext> {
111
- return provider.getContext()
112
- }
113
-
114
- export function listColumns(provider: KanbanProvider): Promise<Column[]> {
115
- return provider.listColumns()
116
- }
117
-
118
- export function getActivity(
119
- provider: KanbanProvider,
120
- limit?: number,
121
- taskId?: string,
122
- ): Promise<ActivityEntry[]> {
123
- return provider.getActivity(limit, taskId)
124
- }
125
-
126
- export function getMetrics(provider: KanbanProvider): Promise<BoardMetrics> {
127
- return provider.getMetrics()
128
- }
129
-
130
- export function getConfig(provider: KanbanProvider): Promise<BoardConfig> {
131
- return provider.getConfig()
132
- }
133
-
134
- export function patchConfig(
135
- provider: KanbanProvider,
136
- input: Partial<BoardConfig>,
137
- ): Promise<BoardConfig> {
138
- return provider.patchConfig(input)
14
+ return { ...rest, labels: normalizeLabels(labels) }
139
15
  }
@@ -25,7 +25,7 @@
25
25
  import type { JSONValue, Sql } from 'postgres'
26
26
 
27
27
  import type { TrackerProvider } from './tracker-config'
28
- import type { WebhookResult } from './webhooks'
28
+ import type { WebhookRequest, WebhookResult } from './webhooks'
29
29
 
30
30
  export type WebhookEventStatus = 'accepted' | 'skipped' | 'error'
31
31
 
@@ -70,6 +70,34 @@ export function webhookEventStatus(result: WebhookResult): WebhookEventStatus {
70
70
  return result.handled ? 'accepted' : 'skipped'
71
71
  }
72
72
 
73
+ /** Run a webhook dispatch and record its outcome (accepted/skipped/error) fire-and-forget. */
74
+ export async function withWebhookRecording(
75
+ sql: Sql,
76
+ provider: TrackerProvider,
77
+ payload: WebhookRequest,
78
+ dispatch: () => Promise<WebhookResult>,
79
+ ): Promise<WebhookResult> {
80
+ const meta = extractWebhookMeta(provider, payload.rawBody)
81
+ let result: WebhookResult
82
+ try {
83
+ result = await dispatch()
84
+ } catch (err) {
85
+ void recordWebhookEvent(sql, {
86
+ provider,
87
+ ...meta,
88
+ status: 'error',
89
+ detail: { error: err instanceof Error ? err.message : String(err) },
90
+ })
91
+ throw err
92
+ }
93
+ void recordWebhookEvent(sql, {
94
+ provider,
95
+ ...meta,
96
+ status: webhookEventStatus(result),
97
+ })
98
+ return result
99
+ }
100
+
73
101
  /** Append a receipt. Swallows every error — a logging miss must never fail the webhook. */
74
102
  export async function recordWebhookEvent(sql: Sql, record: WebhookEventRecord): Promise<void> {
75
103
  if (!webhookEventsEnabled()) return