@pikku/kysely 0.13.23 → 0.13.27

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 (80) hide show
  1. package/CHANGELOG.md +248 -0
  2. package/dist/src/index.d.ts +4 -1
  3. package/dist/src/index.js +3 -1
  4. package/dist/src/kysely-analytics-service.d.ts +30 -0
  5. package/dist/src/kysely-analytics-service.js +80 -0
  6. package/dist/src/kysely-feature-flag-store.d.ts +37 -0
  7. package/dist/src/kysely-feature-flag-store.js +239 -0
  8. package/dist/src/kysely-tables.d.ts +45 -0
  9. package/dist/src/kysely-webhook-service.d.ts +1 -1
  10. package/dist/src/kysely-webhook-service.js +1 -1
  11. package/dist/src/schema/analytics.schema.d.ts +17 -0
  12. package/dist/src/schema/analytics.schema.js +48 -0
  13. package/dist/src/schema/flag.schema.d.ts +16 -0
  14. package/dist/src/schema/flag.schema.js +62 -0
  15. package/dist/src/schema/index.d.ts +2 -0
  16. package/dist/src/schema/index.js +6 -0
  17. package/package.json +11 -6
  18. package/run-tests.sh +9 -1
  19. package/bin/pikku-kysely-pure.ts +0 -59
  20. package/dist/src/kysely-lock-vault.d.ts +0 -34
  21. package/dist/src/kysely-lock-vault.js +0 -77
  22. package/dist/src/schema/data-lock.schema.d.ts +0 -15
  23. package/dist/src/schema/data-lock.schema.js +0 -27
  24. package/dist/tsconfig.tsbuildinfo +0 -1
  25. package/src/classification-crypto.test.ts +0 -143
  26. package/src/classification-crypto.ts +0 -163
  27. package/src/classification-plugin.pglite.test.ts +0 -226
  28. package/src/classification-plugin.test.ts +0 -265
  29. package/src/classification-plugin.ts +0 -249
  30. package/src/coercion-plugin.test.ts +0 -160
  31. package/src/coercion-plugin.ts +0 -187
  32. package/src/create-audited-kysely.test.ts +0 -560
  33. package/src/create-audited-kysely.ts +0 -254
  34. package/src/index.ts +0 -81
  35. package/src/kysely-agent-run-scores.ts +0 -47
  36. package/src/kysely-agent-run-service.ts +0 -244
  37. package/src/kysely-agent-run-state-service.ts +0 -200
  38. package/src/kysely-agent-storage-service.ts +0 -632
  39. package/src/kysely-audit-service.test.ts +0 -352
  40. package/src/kysely-audit-service.ts +0 -237
  41. package/src/kysely-channel-store.ts +0 -107
  42. package/src/kysely-credential-service.ts +0 -286
  43. package/src/kysely-deployment-service.ts +0 -163
  44. package/src/kysely-eventhub-store.ts +0 -53
  45. package/src/kysely-json.ts +0 -5
  46. package/src/kysely-scope-service.test.ts +0 -418
  47. package/src/kysely-scope-service.ts +0 -425
  48. package/src/kysely-secret-service.ts +0 -242
  49. package/src/kysely-services.test.ts +0 -405
  50. package/src/kysely-session-store.ts +0 -57
  51. package/src/kysely-tables.test.ts +0 -110
  52. package/src/kysely-tables.ts +0 -434
  53. package/src/kysely-virtual-user-run-store.test.ts +0 -369
  54. package/src/kysely-virtual-user-run-store.ts +0 -235
  55. package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
  56. package/src/kysely-virtual-user-schedule-store.ts +0 -187
  57. package/src/kysely-webhook-service.test.ts +0 -124
  58. package/src/kysely-webhook-service.ts +0 -137
  59. package/src/kysely-workflow-mirror.test.ts +0 -259
  60. package/src/kysely-workflow-mirror.ts +0 -374
  61. package/src/kysely-workflow-run-service.ts +0 -305
  62. package/src/kysely-workflow-service.test.ts +0 -544
  63. package/src/kysely-workflow-service.ts +0 -820
  64. package/src/schema/agent.schema.ts +0 -162
  65. package/src/schema/audit.schema.ts +0 -57
  66. package/src/schema/channel.schema.ts +0 -38
  67. package/src/schema/credential.schema.ts +0 -58
  68. package/src/schema/deployment.schema.ts +0 -47
  69. package/src/schema/index.ts +0 -441
  70. package/src/schema/pikku-schema.types.ts +0 -169
  71. package/src/schema/schema.test.ts +0 -591
  72. package/src/schema/scope.schema.ts +0 -110
  73. package/src/schema/secret.schema.ts +0 -49
  74. package/src/schema/session.schema.ts +0 -20
  75. package/src/schema/virtual-user-schedule.schema.ts +0 -48
  76. package/src/schema/virtual-user.schema.ts +0 -106
  77. package/src/schema/webhook.schema.ts +0 -58
  78. package/src/schema/workflow.schema.ts +0 -150
  79. package/src/serialize-plugin.ts +0 -233
  80. package/tsconfig.json +0 -17
@@ -1,187 +0,0 @@
1
- import { describe, test, beforeEach } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import Database from 'better-sqlite3'
4
- import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'
5
-
6
- import { KyselyVirtualUserScheduleStore } from './kysely-virtual-user-schedule-store.js'
7
- import { SerializePlugin } from './serialize-plugin.js'
8
- import { applyPikkuSchemas, virtualUserScheduleSchema } from './schema/index.js'
9
- import type { KyselyPikkuDB } from './kysely-tables.js'
10
-
11
- // With the plugin, because most projects in this package run with it and it is
12
- // the harder case: it deserialises columns the store has already stringified.
13
- const withPlugin = () =>
14
- new Kysely<KyselyPikkuDB>({
15
- dialect: new SqliteDialect({ database: new Database(':memory:') }),
16
- plugins: [new CamelCasePlugin(), new SerializePlugin()],
17
- })
18
-
19
- const bare = () =>
20
- new Kysely<KyselyPikkuDB>({
21
- dialect: new SqliteDialect({ database: new Database(':memory:') }),
22
- plugins: [new CamelCasePlugin()],
23
- })
24
-
25
- const HOUR = 60 * 60 * 1000
26
-
27
- describe('KyselyVirtualUserScheduleStore', () => {
28
- let db: Kysely<KyselyPikkuDB>
29
- let store: KyselyVirtualUserScheduleStore
30
-
31
- beforeEach(async () => {
32
- db = withPlugin()
33
- await applyPikkuSchemas(db, [virtualUserScheduleSchema])
34
- store = new KyselyVirtualUserScheduleStore(db)
35
- })
36
-
37
- test('a persona given a cadence is not running until someone says so', async () => {
38
- const written = await store.set({ persona: 'guest' })
39
- assert.equal(written.enabled, false)
40
- assert.deepEqual(await store.due(new Date(Date.now() + 1000)), [])
41
- assert.equal((await store.get('guest'))?.enabled, false)
42
- })
43
-
44
- test('a schedule written without an interval gets one, sparse by default', async () => {
45
- const written = await store.set({ persona: 'guest' })
46
- assert.ok(written.minIntervalMs >= HOUR)
47
- assert.ok(written.maxIntervalMs >= written.minIntervalMs)
48
- const stored = await store.get('guest')
49
- assert.equal(stored?.minIntervalMs, written.minIntervalMs)
50
- assert.equal(stored?.maxIntervalMs, written.maxIntervalMs)
51
- })
52
-
53
- test('setting one field leaves the rest of the schedule alone', async () => {
54
- await store.set({
55
- persona: 'guest',
56
- enabled: true,
57
- disposition: 'adversarial',
58
- goals: ['book a room'],
59
- budget: { steps: 40 },
60
- minIntervalMs: HOUR,
61
- maxIntervalMs: 2 * HOUR,
62
- })
63
- await store.set({ persona: 'guest', enabled: false })
64
-
65
- const stored = await store.get('guest')
66
- assert.equal(stored?.enabled, false)
67
- assert.equal(stored?.disposition, 'adversarial')
68
- assert.deepEqual(stored?.goals, ['book a room'])
69
- assert.deepEqual(stored?.budget, { steps: 40 })
70
- assert.equal(stored?.minIntervalMs, HOUR)
71
- })
72
-
73
- test('only enabled personas whose time has come come back as due', async () => {
74
- const now = new Date()
75
- await store.set({
76
- persona: 'due',
77
- enabled: true,
78
- nextRunAt: new Date(now.getTime() - HOUR),
79
- })
80
- await store.set({
81
- persona: 'later',
82
- enabled: true,
83
- nextRunAt: new Date(now.getTime() + HOUR),
84
- })
85
- await store.set({
86
- persona: 'off',
87
- enabled: false,
88
- nextRunAt: new Date(now.getTime() - HOUR),
89
- })
90
-
91
- assert.deepEqual(
92
- (await store.due(now)).map((row) => row.persona),
93
- ['due']
94
- )
95
- })
96
-
97
- test('claiming pushes the persona out of the due list', async () => {
98
- const now = new Date()
99
- const due = new Date(now.getTime() - HOUR)
100
- await store.set({ persona: 'guest', enabled: true, nextRunAt: due })
101
- const acquired = await store.claim('guest', {
102
- from: due,
103
- nextRunAt: new Date(now.getTime() + HOUR),
104
- runId: 'run-1',
105
- at: now,
106
- })
107
-
108
- assert.equal(acquired, true)
109
-
110
- assert.deepEqual(await store.due(now), [])
111
- const stored = await store.get('guest')
112
- assert.equal(stored?.lastRunId, 'run-1')
113
- assert.equal(stored?.lastRunAt?.getTime(), now.getTime())
114
- })
115
-
116
- test('the claim written before a run starts does not blank the last run', async () => {
117
- const now = new Date()
118
- const written = await store.set({ persona: 'guest', enabled: true })
119
- await store.claim('guest', {
120
- from: written.nextRunAt,
121
- nextRunAt: now,
122
- runId: 'run-1',
123
- at: now,
124
- })
125
- await store.claim('guest', {
126
- from: now,
127
- nextRunAt: new Date(now.getTime() + HOUR),
128
- runId: null,
129
- at: now,
130
- })
131
-
132
- assert.equal((await store.get('guest'))?.lastRunId, 'run-1')
133
- })
134
-
135
- test('only one of two ticks holding the same due row can claim it', async () => {
136
- const now = new Date()
137
- const due = new Date(now.getTime() - HOUR)
138
- await store.set({ persona: 'guest', enabled: true, nextRunAt: due })
139
-
140
- const [first] = await store.due(now)
141
- const [second] = await store.due(now)
142
- assert.equal(first?.nextRunAt.getTime(), second?.nextRunAt.getTime())
143
-
144
- const won = await store.claim('guest', {
145
- from: first!.nextRunAt,
146
- nextRunAt: new Date(now.getTime() + HOUR),
147
- runId: 'run-1',
148
- at: now,
149
- })
150
- const lost = await store.claim('guest', {
151
- from: second!.nextRunAt,
152
- nextRunAt: new Date(now.getTime() + 2 * HOUR),
153
- runId: 'run-2',
154
- at: now,
155
- })
156
-
157
- assert.deepEqual([won, lost], [true, false])
158
- assert.equal((await store.get('guest'))?.lastRunId, 'run-1')
159
- })
160
-
161
- test('a cadence survives a store with no serialize plugin', async () => {
162
- const plainDb = bare()
163
- await applyPikkuSchemas(plainDb, [virtualUserScheduleSchema])
164
- const plain = new KyselyVirtualUserScheduleStore(plainDb)
165
- await plain.set({
166
- persona: 'guest',
167
- enabled: true,
168
- goals: ['book a room'],
169
- budget: { steps: 40, duration: '30m' },
170
- minIntervalMs: HOUR,
171
- maxIntervalMs: 2 * HOUR,
172
- })
173
-
174
- const stored = await plain.get('guest')
175
- assert.equal(stored?.enabled, true)
176
- assert.deepEqual(stored?.goals, ['book a room'])
177
- assert.deepEqual(stored?.budget, { steps: 40, duration: '30m' })
178
- assert.ok(stored?.nextRunAt instanceof Date)
179
- })
180
-
181
- test('a persona can be taken off the clock entirely', async () => {
182
- await store.set({ persona: 'guest', enabled: true })
183
- await store.remove('guest')
184
- assert.equal(await store.get('guest'), null)
185
- assert.deepEqual(await store.list(), [])
186
- })
187
- })
@@ -1,187 +0,0 @@
1
- import type { Kysely, Selectable } from 'kysely'
2
- import type {
3
- VirtualUserBudget,
4
- VirtualUserDisposition,
5
- VirtualUserScheduleInput,
6
- VirtualUserScheduleRecord,
7
- VirtualUserScheduleStore,
8
- } from '@pikku/core/virtual-user'
9
- import {
10
- DEFAULT_MAX_INTERVAL_MS,
11
- DEFAULT_MIN_INTERVAL_MS,
12
- } from '@pikku/core/virtual-user'
13
- import type {
14
- KyselyPikkuDB,
15
- VirtualUserScheduleTable,
16
- } from './kysely-tables.js'
17
- import { parseJson } from './kysely-json.js'
18
- import { requirePikkuSchema } from './schema/index.js'
19
- import { virtualUserScheduleSchema } from './schema/virtual-user-schedule.schema.js'
20
-
21
- /**
22
- * Keeps each persona's cadence in a `virtualUserSchedule` table.
23
- *
24
- * Wiring this is what turns virtual users from something a person triggers into
25
- * something that happens: without it, {@link KyselyVirtualUserRunStore} still
26
- * records every run, and every run still had someone behind it. Two services
27
- * rather than one so that an app can want the history without wanting the bill.
28
- *
29
- * Reads and writes the same way the run store does — JSON as text, timestamps
30
- * as ISO strings, flags as 0/1 — so a project on SQLite and one on Postgres
31
- * hold the same bytes and `SerializePlugin` is optional on both.
32
- */
33
- export class KyselyVirtualUserScheduleStore implements VirtualUserScheduleStore {
34
- private initialized = false
35
-
36
- constructor(private db: Kysely<KyselyPikkuDB>) {}
37
-
38
- public async init(): Promise<void> {
39
- if (this.initialized) return
40
- await requirePikkuSchema(this.db, virtualUserScheduleSchema)
41
- this.initialized = true
42
- }
43
-
44
- async set(
45
- schedule: VirtualUserScheduleInput
46
- ): Promise<VirtualUserScheduleRecord> {
47
- await this.init()
48
- // Read-then-write rather than a partial upsert: a schedule is configuration
49
- // written by a person, and the alternative is two dialects of "leave the
50
- // columns I did not mention alone".
51
- const existing = await this.get(schedule.persona)
52
- const merged: VirtualUserScheduleRecord = {
53
- persona: schedule.persona,
54
- enabled: schedule.enabled ?? existing?.enabled ?? false,
55
- disposition: schedule.disposition ?? existing?.disposition ?? 'realistic',
56
- goals: [...(schedule.goals ?? existing?.goals ?? [])],
57
- budget:
58
- schedule.budget === undefined
59
- ? (existing?.budget ?? null)
60
- : schedule.budget,
61
- minIntervalMs:
62
- schedule.minIntervalMs ??
63
- existing?.minIntervalMs ??
64
- DEFAULT_MIN_INTERVAL_MS,
65
- maxIntervalMs:
66
- schedule.maxIntervalMs ??
67
- existing?.maxIntervalMs ??
68
- DEFAULT_MAX_INTERVAL_MS,
69
- // A new schedule is due immediately. Enabling one is a deliberate act, and
70
- // waiting a random half-day to find out whether it works is not a feature.
71
- nextRunAt: schedule.nextRunAt ?? existing?.nextRunAt ?? new Date(),
72
- lastRunId: existing?.lastRunId ?? null,
73
- lastRunAt: existing?.lastRunAt ?? null,
74
- }
75
-
76
- const values = {
77
- persona: merged.persona,
78
- enabled: merged.enabled ? 1 : 0,
79
- disposition: merged.disposition,
80
- goals: JSON.stringify(merged.goals),
81
- budget: merged.budget ? JSON.stringify(merged.budget) : null,
82
- minIntervalMs: String(merged.minIntervalMs),
83
- maxIntervalMs: String(merged.maxIntervalMs),
84
- nextRunAt: merged.nextRunAt.toISOString(),
85
- }
86
-
87
- await this.db
88
- .insertInto('virtualUserSchedule')
89
- .values(values)
90
- .onConflict((oc) =>
91
- oc.column('persona').doUpdateSet({
92
- enabled: values.enabled,
93
- disposition: values.disposition,
94
- goals: values.goals,
95
- budget: values.budget,
96
- minIntervalMs: values.minIntervalMs,
97
- maxIntervalMs: values.maxIntervalMs,
98
- nextRunAt: values.nextRunAt,
99
- })
100
- )
101
- .execute()
102
-
103
- return merged
104
- }
105
-
106
- async get(persona: string): Promise<VirtualUserScheduleRecord | null> {
107
- await this.init()
108
- const row = await this.db
109
- .selectFrom('virtualUserSchedule')
110
- .selectAll()
111
- .where('persona', '=', persona)
112
- .executeTakeFirst()
113
- return row ? this.toRecord(row) : null
114
- }
115
-
116
- async list(): Promise<VirtualUserScheduleRecord[]> {
117
- await this.init()
118
- const rows = await this.db
119
- .selectFrom('virtualUserSchedule')
120
- .selectAll()
121
- .orderBy('persona', 'asc')
122
- .execute()
123
- return rows.map((row) => this.toRecord(row))
124
- }
125
-
126
- async due(now: Date): Promise<VirtualUserScheduleRecord[]> {
127
- await this.init()
128
- const rows = await this.db
129
- .selectFrom('virtualUserSchedule')
130
- .selectAll()
131
- .where('enabled', '=', 1)
132
- .where('nextRunAt', '<=', now.toISOString())
133
- .orderBy('nextRunAt', 'asc')
134
- .execute()
135
- return rows.map((row) => this.toRecord(row))
136
- }
137
-
138
- async claim(
139
- persona: string,
140
- claim: { from: Date; nextRunAt: Date; runId: string | null; at: Date }
141
- ): Promise<boolean> {
142
- await this.init()
143
- const result = await this.db
144
- .updateTable('virtualUserSchedule')
145
- .set({
146
- nextRunAt: claim.nextRunAt.toISOString(),
147
- // A claim with no run id is the one written before dispatch, and must
148
- // not blank out which run this persona last managed to start.
149
- ...(claim.runId
150
- ? { lastRunId: claim.runId, lastRunAt: claim.at.toISOString() }
151
- : {}),
152
- })
153
- .where('persona', '=', persona)
154
- // The compare-and-set: whoever writes first moves `nextRunAt`, and every
155
- // other tick holding the value they read matches nothing.
156
- .where('nextRunAt', '=', claim.from.toISOString())
157
- .executeTakeFirst()
158
- return Number(result.numUpdatedRows ?? 0) > 0
159
- }
160
-
161
- async remove(persona: string): Promise<void> {
162
- await this.init()
163
- await this.db
164
- .deleteFrom('virtualUserSchedule')
165
- .where('persona', '=', persona)
166
- .execute()
167
- }
168
-
169
- private toRecord(
170
- row: Selectable<VirtualUserScheduleTable>
171
- ): VirtualUserScheduleRecord {
172
- return {
173
- persona: row.persona,
174
- enabled: Boolean(row.enabled),
175
- disposition: row.disposition as VirtualUserDisposition,
176
- goals: parseJson(row.goals) ?? [],
177
- budget: row.budget
178
- ? ((parseJson(row.budget) ?? null) as VirtualUserBudget | null)
179
- : null,
180
- minIntervalMs: Number(row.minIntervalMs),
181
- maxIntervalMs: Number(row.maxIntervalMs),
182
- nextRunAt: new Date(row.nextRunAt),
183
- lastRunId: row.lastRunId,
184
- lastRunAt: row.lastRunAt ? new Date(row.lastRunAt) : null,
185
- }
186
- }
187
- }
@@ -1,124 +0,0 @@
1
- import { describe, test, beforeEach } from 'node:test'
2
- import assert from 'node:assert/strict'
3
- import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'
4
- import Database from 'better-sqlite3'
5
- import { setSingletonServices } from '@pikku/core/state'
6
-
7
- import type { KyselyPikkuDB } from './kysely-tables.js'
8
- import { SerializePlugin } from './serialize-plugin.js'
9
- import { KyselyWebhookService } from './kysely-webhook-service.js'
10
- import { applyPikkuSchemas, webhookSchema } from './schema/index.js'
11
-
12
- const noopLogger = { error() {}, info() {}, warn() {}, debug() {} }
13
-
14
- function createDb(): Kysely<KyselyPikkuDB> {
15
- return new Kysely<KyselyPikkuDB>({
16
- dialect: new SqliteDialect({ database: new Database(':memory:') }),
17
- plugins: [new CamelCasePlugin(), new SerializePlugin()],
18
- })
19
- }
20
-
21
- const createQueue = () => {
22
- const added: { data: any; options: any }[] = []
23
- return {
24
- added,
25
- queue: {
26
- supportsResults: false,
27
- add: async (_name: string, data: any, options: any) => {
28
- added.push({ data, options })
29
- return options?.jobId ?? 'job-1'
30
- },
31
- getJob: async () => null,
32
- },
33
- }
34
- }
35
-
36
- // send() resolves config/secrets through the singleton services.
37
- const setServices = () =>
38
- setSingletonServices({
39
- logger: noopLogger,
40
- config: {},
41
- secrets: {
42
- getSecret: async (key: string) => {
43
- throw new Error(`Secret not found: ${key}`)
44
- },
45
- },
46
- } as any)
47
-
48
- describe('KyselyWebhookService', () => {
49
- beforeEach(setServices)
50
-
51
- test('send() inserts a pending delivery and enqueues with deliveryId === jobId', async () => {
52
- const db = createDb()
53
- const { added, queue } = createQueue()
54
- await applyPikkuSchemas(db, [webhookSchema])
55
- const service = new KyselyWebhookService(queue as any, db)
56
- await service.init()
57
-
58
- const { jobId } = await service.send({
59
- url: 'https://example.com/hook',
60
- event: 'user.created',
61
- data: { id: 'u1' },
62
- organizationId: 'org-1',
63
- })
64
-
65
- // jobId is the delivery id and rides in the payload.
66
- assert.equal(added.length, 1)
67
- assert.equal(added[0]!.options.jobId, jobId)
68
- assert.equal(added[0]!.data.deliveryId, jobId)
69
-
70
- const delivery = await service.getDelivery(jobId)
71
- assert.ok(delivery)
72
- assert.equal(delivery!.delivery.status, 'pending')
73
- assert.equal(delivery!.delivery.organizationId, 'org-1')
74
- assert.equal(delivery!.delivery.url, 'https://example.com/hook')
75
- assert.equal(delivery!.attempts.length, 0)
76
- })
77
-
78
- test('recordAttempt() appends attempts and rolls status forward', async () => {
79
- const db = createDb()
80
- const { queue } = createQueue()
81
- await applyPikkuSchemas(db, [webhookSchema])
82
- const service = new KyselyWebhookService(queue as any, db)
83
- await service.init()
84
-
85
- const { jobId } = await service.send({
86
- url: 'https://example.com/hook',
87
- data: {},
88
- })
89
-
90
- await service.recordAttempt(jobId, {
91
- statusCode: 500,
92
- error: 'boom',
93
- delivered: false,
94
- })
95
- await service.recordAttempt(jobId, { statusCode: 200, delivered: true })
96
-
97
- const result = await service.getDelivery(jobId)
98
- assert.equal(result!.attempts.length, 2)
99
- assert.equal(result!.attempts[0]!.attemptNumber, 1)
100
- assert.equal(result!.attempts[0]!.statusCode, 500)
101
- assert.equal(result!.attempts[1]!.attemptNumber, 2)
102
- assert.equal(result!.delivery.status, 'delivered')
103
- assert.equal(result!.delivery.attempts, 2)
104
- assert.ok(result!.delivery.deliveredAt)
105
- })
106
-
107
- test('listDeliveries() filters by organization', async () => {
108
- const db = createDb()
109
- const { queue } = createQueue()
110
- await applyPikkuSchemas(db, [webhookSchema])
111
- const service = new KyselyWebhookService(queue as any, db)
112
- await service.init()
113
-
114
- await service.send({ url: 'https://a.com', data: {}, organizationId: 'o1' })
115
- await service.send({ url: 'https://b.com', data: {}, organizationId: 'o2' })
116
-
117
- const o1 = await service.listDeliveries({ organizationId: 'o1' })
118
- assert.equal(o1.length, 1)
119
- assert.equal(o1[0]!.url, 'https://a.com')
120
-
121
- const all = await service.listDeliveries()
122
- assert.equal(all.length, 2)
123
- })
124
- })
@@ -1,137 +0,0 @@
1
- import { QueueWebhookService } from '@pikku/core/services'
2
- import type { QueueService } from '@pikku/core/queue'
3
- import {
4
- PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME,
5
- type SendWebhookInput,
6
- type SendWebhookResult,
7
- type WebhookAttemptResult,
8
- type WebhookDeliveryRecord,
9
- type WebhookDeliveryWithAttempts,
10
- } from '@pikku/core/services'
11
- import type { Kysely } from 'kysely'
12
- import type { KyselyPikkuDB } from './kysely-tables.js'
13
- import { requirePikkuSchema } from './schema/index.js'
14
- import { webhookSchema } from './schema/webhook.schema.js'
15
-
16
- /**
17
- * Durable {@link QueueWebhookService}: still delivers through the
18
- * `pikku-outgoing-webhooks` queue, but records a `webhook_delivery` row per
19
- * `send()` and one `webhook_delivery_attempt` row per try. Register it as the
20
- * `webhookService` singleton; the queue worker persists each attempt through
21
- * {@link recordAttempt}, overriding the base's throwing default.
22
- */
23
- export class KyselyWebhookService extends QueueWebhookService {
24
- private initialized = false
25
-
26
- constructor(
27
- queueService: QueueService,
28
- private db: Kysely<KyselyPikkuDB>
29
- ) {
30
- super(queueService)
31
- }
32
-
33
- public async init(): Promise<void> {
34
- if (this.initialized) return
35
- await requirePikkuSchema(this.db, webhookSchema)
36
- this.initialized = true
37
- }
38
-
39
- public async send(input: SendWebhookInput): Promise<SendWebhookResult> {
40
- const deliveryId = globalThis.crypto.randomUUID()
41
- const { jobData, options } = await this.prepareDelivery(input)
42
-
43
- await this.db
44
- .insertInto('webhookDelivery')
45
- .values({
46
- deliveryId,
47
- organizationId: input.organizationId ?? null,
48
- url: input.url,
49
- event: input.event ?? null,
50
- })
51
- .execute()
52
-
53
- // deliveryId doubles as the queue jobId (idempotency) and rides in the
54
- // payload so the worker can record attempts against this row.
55
- const jobId = await this.queueService.add(
56
- PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME,
57
- { ...jobData, deliveryId },
58
- { ...options, jobId: deliveryId }
59
- )
60
- return { jobId }
61
- }
62
-
63
- public async recordAttempt(
64
- deliveryId: string,
65
- { statusCode, responseBody, error, delivered }: WebhookAttemptResult
66
- ): Promise<void> {
67
- const now = new Date()
68
- await this.db.transaction().execute(async (trx) => {
69
- const existing = await trx
70
- .selectFrom('webhookDeliveryAttempt')
71
- .select((eb) => eb.fn.countAll<number>().as('count'))
72
- .where('deliveryId', '=', deliveryId)
73
- .executeTakeFirst()
74
- const attemptNumber = Number(existing?.count ?? 0) + 1
75
-
76
- await trx
77
- .insertInto('webhookDeliveryAttempt')
78
- .values({
79
- attemptId: globalThis.crypto.randomUUID(),
80
- deliveryId,
81
- attemptNumber,
82
- statusCode: statusCode ?? null,
83
- responseBody: responseBody ?? null,
84
- error: error ?? null,
85
- })
86
- .execute()
87
-
88
- await trx
89
- .updateTable('webhookDelivery')
90
- .set({
91
- status: delivered ? 'delivered' : 'failed',
92
- attempts: attemptNumber,
93
- updatedAt: now,
94
- ...(delivered ? { deliveredAt: now } : {}),
95
- })
96
- .where('deliveryId', '=', deliveryId)
97
- .execute()
98
- })
99
- }
100
-
101
- /** List deliveries, most recent first — for the console webhooks view. */
102
- public async listDeliveries(opts?: {
103
- organizationId?: string
104
- limit?: number
105
- }): Promise<WebhookDeliveryRecord[]> {
106
- let query = this.db
107
- .selectFrom('webhookDelivery')
108
- .selectAll()
109
- .orderBy('createdAt', 'desc')
110
- .limit(opts?.limit ?? 100)
111
- if (opts?.organizationId) {
112
- query = query.where('organizationId', '=', opts.organizationId)
113
- }
114
- return query.execute()
115
- }
116
-
117
- /** A single delivery with its full attempt history, or null if unknown. */
118
- public async getDelivery(
119
- deliveryId: string
120
- ): Promise<WebhookDeliveryWithAttempts | null> {
121
- const delivery = await this.db
122
- .selectFrom('webhookDelivery')
123
- .selectAll()
124
- .where('deliveryId', '=', deliveryId)
125
- .executeTakeFirst()
126
- if (!delivery) {
127
- return null
128
- }
129
- const attempts = await this.db
130
- .selectFrom('webhookDeliveryAttempt')
131
- .selectAll()
132
- .where('deliveryId', '=', deliveryId)
133
- .orderBy('attemptNumber', 'asc')
134
- .execute()
135
- return { delivery, attempts }
136
- }
137
- }