@pikku/kysely 0.13.0 → 0.13.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.
@@ -0,0 +1,200 @@
1
+ import {
2
+ PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME,
3
+ QueueWebhookService,
4
+ type SendWebhookInput,
5
+ type SendWebhookResult,
6
+ type WebhookAttemptResult,
7
+ type WebhookDeliveryRecord,
8
+ type WebhookDeliveryWithAttempts,
9
+ } from '@pikku/core/services'
10
+ import type { QueueService } from '@pikku/core/queue'
11
+ import type { Kysely } from 'kysely'
12
+ import { sql } from 'kysely'
13
+ import type { KyselyPikkuDB } from './kysely-tables.js'
14
+
15
+ /**
16
+ * Durable {@link QueueWebhookService}: still delivers through the
17
+ * `pikku-outgoing-webhooks` queue, but records a `webhook_delivery` row per
18
+ * `send()` and one `webhook_delivery_attempt` row per try. Register it as the
19
+ * `webhookService` singleton; the queue worker persists each attempt through
20
+ * {@link recordAttempt}, overriding the base's throwing default.
21
+ */
22
+ export class KyselyWebhookService extends QueueWebhookService {
23
+ private initialized = false
24
+
25
+ constructor(
26
+ queueService: QueueService,
27
+ private db: Kysely<KyselyPikkuDB>
28
+ ) {
29
+ super(queueService)
30
+ }
31
+
32
+ public async init(): Promise<void> {
33
+ if (this.initialized) {
34
+ return
35
+ }
36
+
37
+ await this.db.schema
38
+ .createTable('webhook_delivery')
39
+ .ifNotExists()
40
+ .addColumn('delivery_id', 'text', (col) => col.primaryKey())
41
+ .addColumn('organization_id', 'text')
42
+ .addColumn('url', 'text', (col) => col.notNull())
43
+ .addColumn('event', 'text')
44
+ .addColumn('status', 'text', (col) => col.defaultTo('pending').notNull())
45
+ .addColumn('attempts', 'integer', (col) => col.defaultTo(0).notNull())
46
+ .addColumn('created_at', 'timestamp', (col) =>
47
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
48
+ )
49
+ .addColumn('updated_at', 'timestamp', (col) =>
50
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
51
+ )
52
+ .addColumn('delivered_at', 'timestamp')
53
+ .execute()
54
+
55
+ await this.db.schema
56
+ .createTable('webhook_delivery_attempt')
57
+ .ifNotExists()
58
+ .addColumn('attempt_id', 'text', (col) => col.primaryKey())
59
+ .addColumn('delivery_id', 'text', (col) =>
60
+ col.notNull().references('webhook_delivery.delivery_id').onDelete('cascade')
61
+ )
62
+ .addColumn('attempt_number', 'integer', (col) => col.notNull())
63
+ .addColumn('status_code', 'integer')
64
+ .addColumn('response_body', 'text')
65
+ .addColumn('error', 'text')
66
+ .addColumn('created_at', 'timestamp', (col) =>
67
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
68
+ )
69
+ .execute()
70
+
71
+ await this.createIndexSafe(
72
+ this.db.schema
73
+ .createIndex('idx_webhook_delivery_org')
74
+ .ifNotExists()
75
+ .on('webhook_delivery')
76
+ .column('organization_id')
77
+ )
78
+ await this.createIndexSafe(
79
+ this.db.schema
80
+ .createIndex('idx_webhook_delivery_attempt_delivery')
81
+ .ifNotExists()
82
+ .on('webhook_delivery_attempt')
83
+ .column('delivery_id')
84
+ )
85
+
86
+ this.initialized = true
87
+ }
88
+
89
+ public async send(input: SendWebhookInput): Promise<SendWebhookResult> {
90
+ const deliveryId = globalThis.crypto.randomUUID()
91
+ const { jobData, options } = await this.prepareDelivery(input)
92
+
93
+ await this.db
94
+ .insertInto('webhookDelivery')
95
+ .values({
96
+ deliveryId,
97
+ organizationId: input.organizationId ?? null,
98
+ url: input.url,
99
+ event: input.event ?? null,
100
+ })
101
+ .execute()
102
+
103
+ // deliveryId doubles as the queue jobId (idempotency) and rides in the
104
+ // payload so the worker can record attempts against this row.
105
+ const jobId = await this.queueService.add(
106
+ PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME,
107
+ { ...jobData, deliveryId },
108
+ { ...options, jobId: deliveryId }
109
+ )
110
+ return { jobId }
111
+ }
112
+
113
+ public async recordAttempt(
114
+ deliveryId: string,
115
+ { statusCode, responseBody, error, delivered }: WebhookAttemptResult
116
+ ): Promise<void> {
117
+ const now = new Date()
118
+ await this.db.transaction().execute(async (trx) => {
119
+ const existing = await trx
120
+ .selectFrom('webhookDeliveryAttempt')
121
+ .select((eb) => eb.fn.countAll<number>().as('count'))
122
+ .where('deliveryId', '=', deliveryId)
123
+ .executeTakeFirst()
124
+ const attemptNumber = Number(existing?.count ?? 0) + 1
125
+
126
+ await trx
127
+ .insertInto('webhookDeliveryAttempt')
128
+ .values({
129
+ attemptId: globalThis.crypto.randomUUID(),
130
+ deliveryId,
131
+ attemptNumber,
132
+ statusCode: statusCode ?? null,
133
+ responseBody: responseBody ?? null,
134
+ error: error ?? null,
135
+ })
136
+ .execute()
137
+
138
+ await trx
139
+ .updateTable('webhookDelivery')
140
+ .set({
141
+ status: delivered ? 'delivered' : 'failed',
142
+ attempts: attemptNumber,
143
+ updatedAt: now,
144
+ ...(delivered ? { deliveredAt: now } : {}),
145
+ })
146
+ .where('deliveryId', '=', deliveryId)
147
+ .execute()
148
+ })
149
+ }
150
+
151
+ /** List deliveries, most recent first — for the console webhooks view. */
152
+ public async listDeliveries(opts?: {
153
+ organizationId?: string
154
+ limit?: number
155
+ }): Promise<WebhookDeliveryRecord[]> {
156
+ let query = this.db
157
+ .selectFrom('webhookDelivery')
158
+ .selectAll()
159
+ .orderBy('createdAt', 'desc')
160
+ .limit(opts?.limit ?? 100)
161
+ if (opts?.organizationId) {
162
+ query = query.where('organizationId', '=', opts.organizationId)
163
+ }
164
+ return query.execute()
165
+ }
166
+
167
+ /** A single delivery with its full attempt history, or null if unknown. */
168
+ public async getDelivery(
169
+ deliveryId: string
170
+ ): Promise<WebhookDeliveryWithAttempts | null> {
171
+ const delivery = await this.db
172
+ .selectFrom('webhookDelivery')
173
+ .selectAll()
174
+ .where('deliveryId', '=', deliveryId)
175
+ .executeTakeFirst()
176
+ if (!delivery) {
177
+ return null
178
+ }
179
+ const attempts = await this.db
180
+ .selectFrom('webhookDeliveryAttempt')
181
+ .selectAll()
182
+ .where('deliveryId', '=', deliveryId)
183
+ .orderBy('attemptNumber', 'asc')
184
+ .execute()
185
+ return { delivery, attempts }
186
+ }
187
+
188
+ private async createIndexSafe(builder: {
189
+ execute(): Promise<void>
190
+ }): Promise<void> {
191
+ try {
192
+ await builder.execute()
193
+ } catch (e: any) {
194
+ if (e?.code === 'ER_DUP_KEYNAME' || e?.errno === 1061) return
195
+ if (e?.code === '42P07') return
196
+ if (e?.message?.includes('already exists')) return
197
+ throw e
198
+ }
199
+ }
200
+ }