@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.
- package/CHANGELOG.md +235 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +3 -1
- package/dist/src/kysely-analytics-service.d.ts +30 -0
- package/dist/src/kysely-analytics-service.js +80 -0
- package/dist/src/kysely-feature-flag-store.d.ts +37 -0
- package/dist/src/kysely-feature-flag-store.js +239 -0
- package/dist/src/kysely-tables.d.ts +45 -0
- package/dist/src/schema/analytics.schema.d.ts +17 -0
- package/dist/src/schema/analytics.schema.js +48 -0
- package/dist/src/schema/flag.schema.d.ts +16 -0
- package/dist/src/schema/flag.schema.js +62 -0
- package/dist/src/schema/index.d.ts +2 -0
- package/dist/src/schema/index.js +6 -0
- package/package.json +11 -6
- package/run-tests.sh +9 -1
- package/bin/pikku-kysely-pure.ts +0 -59
- package/dist/src/kysely-lock-vault.d.ts +0 -34
- package/dist/src/kysely-lock-vault.js +0 -77
- package/dist/src/schema/data-lock.schema.d.ts +0 -15
- package/dist/src/schema/data-lock.schema.js +0 -27
- package/dist/tsconfig.tsbuildinfo +0 -1
- package/src/classification-crypto.test.ts +0 -143
- package/src/classification-crypto.ts +0 -163
- package/src/classification-plugin.pglite.test.ts +0 -226
- package/src/classification-plugin.test.ts +0 -265
- package/src/classification-plugin.ts +0 -249
- package/src/coercion-plugin.test.ts +0 -160
- package/src/coercion-plugin.ts +0 -187
- package/src/create-audited-kysely.test.ts +0 -560
- package/src/create-audited-kysely.ts +0 -254
- package/src/index.ts +0 -81
- package/src/kysely-agent-run-scores.ts +0 -47
- package/src/kysely-agent-run-service.ts +0 -244
- package/src/kysely-agent-run-state-service.ts +0 -200
- package/src/kysely-agent-storage-service.ts +0 -632
- package/src/kysely-audit-service.test.ts +0 -352
- package/src/kysely-audit-service.ts +0 -237
- package/src/kysely-channel-store.ts +0 -107
- package/src/kysely-credential-service.ts +0 -286
- package/src/kysely-deployment-service.ts +0 -163
- package/src/kysely-eventhub-store.ts +0 -53
- package/src/kysely-json.ts +0 -5
- package/src/kysely-scope-service.test.ts +0 -418
- package/src/kysely-scope-service.ts +0 -425
- package/src/kysely-secret-service.ts +0 -242
- package/src/kysely-services.test.ts +0 -405
- package/src/kysely-session-store.ts +0 -57
- package/src/kysely-tables.test.ts +0 -110
- package/src/kysely-tables.ts +0 -434
- package/src/kysely-virtual-user-run-store.test.ts +0 -369
- package/src/kysely-virtual-user-run-store.ts +0 -235
- package/src/kysely-virtual-user-schedule-store.test.ts +0 -187
- package/src/kysely-virtual-user-schedule-store.ts +0 -187
- package/src/kysely-webhook-service.test.ts +0 -153
- package/src/kysely-webhook-service.ts +0 -139
- package/src/kysely-workflow-mirror.test.ts +0 -259
- package/src/kysely-workflow-mirror.ts +0 -374
- package/src/kysely-workflow-run-service.ts +0 -305
- package/src/kysely-workflow-service.test.ts +0 -544
- package/src/kysely-workflow-service.ts +0 -820
- package/src/schema/agent.schema.ts +0 -162
- package/src/schema/audit.schema.ts +0 -57
- package/src/schema/channel.schema.ts +0 -38
- package/src/schema/credential.schema.ts +0 -58
- package/src/schema/deployment.schema.ts +0 -47
- package/src/schema/index.ts +0 -441
- package/src/schema/pikku-schema.types.ts +0 -169
- package/src/schema/schema.test.ts +0 -591
- package/src/schema/scope.schema.ts +0 -110
- package/src/schema/secret.schema.ts +0 -49
- package/src/schema/session.schema.ts +0 -20
- package/src/schema/virtual-user-schedule.schema.ts +0 -48
- package/src/schema/virtual-user.schema.ts +0 -106
- package/src/schema/webhook.schema.ts +0 -58
- package/src/schema/workflow.schema.ts +0 -150
- package/src/serialize-plugin.ts +0 -233
- 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
|
-
}
|