@pikku/kysely 0.13.24 → 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 (78) hide show
  1. package/CHANGELOG.md +235 -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/schema/analytics.schema.d.ts +17 -0
  10. package/dist/src/schema/analytics.schema.js +48 -0
  11. package/dist/src/schema/flag.schema.d.ts +16 -0
  12. package/dist/src/schema/flag.schema.js +62 -0
  13. package/dist/src/schema/index.d.ts +2 -0
  14. package/dist/src/schema/index.js +6 -0
  15. package/package.json +11 -6
  16. package/run-tests.sh +9 -1
  17. package/bin/pikku-kysely-pure.ts +0 -59
  18. package/dist/src/kysely-lock-vault.d.ts +0 -34
  19. package/dist/src/kysely-lock-vault.js +0 -77
  20. package/dist/src/schema/data-lock.schema.d.ts +0 -15
  21. package/dist/src/schema/data-lock.schema.js +0 -27
  22. package/dist/tsconfig.tsbuildinfo +0 -1
  23. package/src/classification-crypto.test.ts +0 -143
  24. package/src/classification-crypto.ts +0 -163
  25. package/src/classification-plugin.pglite.test.ts +0 -226
  26. package/src/classification-plugin.test.ts +0 -265
  27. package/src/classification-plugin.ts +0 -249
  28. package/src/coercion-plugin.test.ts +0 -160
  29. package/src/coercion-plugin.ts +0 -187
  30. package/src/create-audited-kysely.test.ts +0 -560
  31. package/src/create-audited-kysely.ts +0 -254
  32. package/src/index.ts +0 -81
  33. package/src/kysely-agent-run-scores.ts +0 -47
  34. package/src/kysely-agent-run-service.ts +0 -244
  35. package/src/kysely-agent-run-state-service.ts +0 -200
  36. package/src/kysely-agent-storage-service.ts +0 -632
  37. package/src/kysely-audit-service.test.ts +0 -352
  38. package/src/kysely-audit-service.ts +0 -237
  39. package/src/kysely-channel-store.ts +0 -107
  40. package/src/kysely-credential-service.ts +0 -286
  41. package/src/kysely-deployment-service.ts +0 -163
  42. package/src/kysely-eventhub-store.ts +0 -53
  43. package/src/kysely-json.ts +0 -5
  44. package/src/kysely-scope-service.test.ts +0 -418
  45. package/src/kysely-scope-service.ts +0 -425
  46. package/src/kysely-secret-service.ts +0 -242
  47. package/src/kysely-services.test.ts +0 -405
  48. package/src/kysely-session-store.ts +0 -57
  49. package/src/kysely-tables.test.ts +0 -110
  50. package/src/kysely-tables.ts +0 -434
  51. package/src/kysely-virtual-user-run-store.test.ts +0 -369
  52. package/src/kysely-virtual-user-run-store.ts +0 -235
  53. package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
  54. package/src/kysely-virtual-user-schedule-store.ts +0 -187
  55. package/src/kysely-webhook-service.test.ts +0 -153
  56. package/src/kysely-webhook-service.ts +0 -139
  57. package/src/kysely-workflow-mirror.test.ts +0 -259
  58. package/src/kysely-workflow-mirror.ts +0 -374
  59. package/src/kysely-workflow-run-service.ts +0 -305
  60. package/src/kysely-workflow-service.test.ts +0 -544
  61. package/src/kysely-workflow-service.ts +0 -820
  62. package/src/schema/agent.schema.ts +0 -162
  63. package/src/schema/audit.schema.ts +0 -57
  64. package/src/schema/channel.schema.ts +0 -38
  65. package/src/schema/credential.schema.ts +0 -58
  66. package/src/schema/deployment.schema.ts +0 -47
  67. package/src/schema/index.ts +0 -441
  68. package/src/schema/pikku-schema.types.ts +0 -169
  69. package/src/schema/schema.test.ts +0 -591
  70. package/src/schema/scope.schema.ts +0 -110
  71. package/src/schema/secret.schema.ts +0 -49
  72. package/src/schema/session.schema.ts +0 -20
  73. package/src/schema/virtual-user-schedule.schema.ts +0 -48
  74. package/src/schema/virtual-user.schema.ts +0 -106
  75. package/src/schema/webhook.schema.ts +0 -58
  76. package/src/schema/workflow.schema.ts +0 -150
  77. package/src/serialize-plugin.ts +0 -233
  78. 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,153 +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('send() reports the delivery id even when the broker assigns its own', async () => {
79
- const db = createDb()
80
- const added: { data: any; options: any }[] = []
81
- const queue = {
82
- supportsResults: false,
83
- add: async (_name: string, data: any, options: any) => {
84
- added.push({ data, options })
85
- return '42'
86
- },
87
- getJob: async () => null,
88
- }
89
- await applyPikkuSchemas(db, [webhookSchema])
90
- const service = new KyselyWebhookService(queue as any, db)
91
- await service.init()
92
-
93
- const { jobId, deliveryId } = await service.send({
94
- url: 'https://example.com/hook',
95
- event: 'user.created',
96
- data: { id: 'u1' },
97
- organizationId: 'org-1',
98
- })
99
-
100
- assert.equal(jobId, '42')
101
- assert.notEqual(deliveryId, jobId)
102
- assert.equal(deliveryId, added[0]!.options.jobId)
103
- assert.ok(await service.getDelivery(deliveryId!))
104
- assert.equal(await service.getDelivery(jobId), null)
105
- })
106
-
107
- test('recordAttempt() appends attempts and rolls status forward', 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
- const { jobId } = await service.send({
115
- url: 'https://example.com/hook',
116
- data: {},
117
- })
118
-
119
- await service.recordAttempt(jobId, {
120
- statusCode: 500,
121
- error: 'boom',
122
- delivered: false,
123
- })
124
- await service.recordAttempt(jobId, { statusCode: 200, delivered: true })
125
-
126
- const result = await service.getDelivery(jobId)
127
- assert.equal(result!.attempts.length, 2)
128
- assert.equal(result!.attempts[0]!.attemptNumber, 1)
129
- assert.equal(result!.attempts[0]!.statusCode, 500)
130
- assert.equal(result!.attempts[1]!.attemptNumber, 2)
131
- assert.equal(result!.delivery.status, 'delivered')
132
- assert.equal(result!.delivery.attempts, 2)
133
- assert.ok(result!.delivery.deliveredAt)
134
- })
135
-
136
- test('listDeliveries() filters by organization', async () => {
137
- const db = createDb()
138
- const { queue } = createQueue()
139
- await applyPikkuSchemas(db, [webhookSchema])
140
- const service = new KyselyWebhookService(queue as any, db)
141
- await service.init()
142
-
143
- await service.send({ url: 'https://a.com', data: {}, organizationId: 'o1' })
144
- await service.send({ url: 'https://b.com', data: {}, organizationId: 'o2' })
145
-
146
- const o1 = await service.listDeliveries({ organizationId: 'o1' })
147
- assert.equal(o1.length, 1)
148
- assert.equal(o1[0]!.url, 'https://a.com')
149
-
150
- const all = await service.listDeliveries()
151
- assert.equal(all.length, 2)
152
- })
153
- })
@@ -1,139 +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(
40
- input: SendWebhookInput
41
- ): Promise<Required<SendWebhookResult>> {
42
- const deliveryId = globalThis.crypto.randomUUID()
43
- const { jobData, options } = await this.prepareDelivery(input)
44
-
45
- await this.db
46
- .insertInto('webhookDelivery')
47
- .values({
48
- deliveryId,
49
- organizationId: input.organizationId ?? null,
50
- url: input.url,
51
- event: input.event ?? null,
52
- })
53
- .execute()
54
-
55
- // deliveryId doubles as the queue jobId (idempotency) and rides in the
56
- // payload so the worker can record attempts against this row.
57
- const jobId = await this.queueService.add(
58
- PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME,
59
- { ...jobData, deliveryId },
60
- { ...options, jobId: deliveryId }
61
- )
62
- return { jobId, deliveryId }
63
- }
64
-
65
- public async recordAttempt(
66
- deliveryId: string,
67
- { statusCode, responseBody, error, delivered }: WebhookAttemptResult
68
- ): Promise<void> {
69
- const now = new Date()
70
- await this.db.transaction().execute(async (trx) => {
71
- const existing = await trx
72
- .selectFrom('webhookDeliveryAttempt')
73
- .select((eb) => eb.fn.countAll<number>().as('count'))
74
- .where('deliveryId', '=', deliveryId)
75
- .executeTakeFirst()
76
- const attemptNumber = Number(existing?.count ?? 0) + 1
77
-
78
- await trx
79
- .insertInto('webhookDeliveryAttempt')
80
- .values({
81
- attemptId: globalThis.crypto.randomUUID(),
82
- deliveryId,
83
- attemptNumber,
84
- statusCode: statusCode ?? null,
85
- responseBody: responseBody ?? null,
86
- error: error ?? null,
87
- })
88
- .execute()
89
-
90
- await trx
91
- .updateTable('webhookDelivery')
92
- .set({
93
- status: delivered ? 'delivered' : 'failed',
94
- attempts: attemptNumber,
95
- updatedAt: now,
96
- ...(delivered ? { deliveredAt: now } : {}),
97
- })
98
- .where('deliveryId', '=', deliveryId)
99
- .execute()
100
- })
101
- }
102
-
103
- /** List deliveries, most recent first — for the console webhooks view. */
104
- public async listDeliveries(opts?: {
105
- organizationId?: string
106
- limit?: number
107
- }): Promise<WebhookDeliveryRecord[]> {
108
- let query = this.db
109
- .selectFrom('webhookDelivery')
110
- .selectAll()
111
- .orderBy('createdAt', 'desc')
112
- .limit(opts?.limit ?? 100)
113
- if (opts?.organizationId) {
114
- query = query.where('organizationId', '=', opts.organizationId)
115
- }
116
- return query.execute()
117
- }
118
-
119
- /** A single delivery with its full attempt history, or null if unknown. */
120
- public async getDelivery(
121
- deliveryId: string
122
- ): Promise<WebhookDeliveryWithAttempts | null> {
123
- const delivery = await this.db
124
- .selectFrom('webhookDelivery')
125
- .selectAll()
126
- .where('deliveryId', '=', deliveryId)
127
- .executeTakeFirst()
128
- if (!delivery) {
129
- return null
130
- }
131
- const attempts = await this.db
132
- .selectFrom('webhookDeliveryAttempt')
133
- .selectAll()
134
- .where('deliveryId', '=', deliveryId)
135
- .orderBy('attemptNumber', 'asc')
136
- .execute()
137
- return { delivery, attempts }
138
- }
139
- }