@pikku/kysely 0.12.18 → 0.13.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/dist/src/index.d.ts +3 -0
- package/dist/src/index.js +3 -0
- package/dist/src/kysely-audit-service.d.ts +19 -0
- package/dist/src/kysely-audit-service.js +55 -0
- package/dist/src/kysely-scope-service.d.ts +59 -0
- package/dist/src/kysely-scope-service.js +268 -0
- package/dist/src/kysely-tables.d.ts +67 -0
- package/dist/src/kysely-webhook-service.d.ts +27 -0
- package/dist/src/kysely-webhook-service.js +150 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +3 -3
- package/run-tests.sh +4 -4
- package/src/index.ts +3 -0
- package/src/kysely-audit-service.ts +62 -0
- package/src/kysely-scope-service.test.ts +416 -0
- package/src/kysely-scope-service.ts +350 -0
- package/src/kysely-tables.ts +74 -0
- package/src/kysely-webhook-service.test.ts +120 -0
- package/src/kysely-webhook-service.ts +200 -0
|
@@ -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
|
+
}
|