@pikku/kysely 0.13.20 → 0.13.22
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 +144 -0
- package/dist/src/index.d.ts +2 -1
- package/dist/src/index.js +1 -0
- package/dist/src/kysely-secret-service.js +1 -1
- package/dist/src/kysely-tables.d.ts +46 -0
- package/dist/src/kysely-virtual-user-run-store.d.ts +7 -19
- package/dist/src/kysely-virtual-user-run-store.js +69 -2
- package/dist/src/kysely-virtual-user-schedule-store.d.ts +33 -0
- package/dist/src/kysely-virtual-user-schedule-store.js +151 -0
- package/dist/src/schema/index.d.ts +1 -0
- package/dist/src/schema/index.js +3 -0
- package/dist/src/schema/virtual-user-schedule.schema.d.ts +15 -0
- package/dist/src/schema/virtual-user-schedule.schema.js +43 -0
- package/dist/src/schema/virtual-user.schema.d.ts +2 -1
- package/dist/src/schema/virtual-user.schema.js +39 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/index.ts +3 -0
- package/src/kysely-secret-service.ts +1 -1
- package/src/kysely-tables.ts +48 -0
- package/src/kysely-virtual-user-run-store.test.ts +142 -1
- package/src/kysely-virtual-user-run-store.ts +90 -4
- package/src/kysely-virtual-user-schedule-store.test.ts +187 -0
- package/src/kysely-virtual-user-schedule-store.ts +187 -0
- package/src/kysely-workflow-service.test.ts +1 -1
- package/src/schema/index.ts +3 -0
- package/src/schema/virtual-user-schedule.schema.ts +48 -0
- package/src/schema/virtual-user.schema.ts +41 -1
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
})
|
|
@@ -0,0 +1,187 @@
|
|
|
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
|
+
}
|
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict'
|
|
|
3
3
|
import { CamelCasePlugin, Kysely, SqliteDialect, sql } from 'kysely'
|
|
4
4
|
import Database from 'better-sqlite3'
|
|
5
5
|
|
|
6
|
-
import type { StepState } from '@pikku/core/
|
|
6
|
+
import type { StepState } from '@pikku/core/workflow'
|
|
7
7
|
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
8
8
|
import { SerializePlugin } from './serialize-plugin.js'
|
|
9
9
|
import { KyselyWorkflowService } from './kysely-workflow-service.js'
|
package/src/schema/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import { sessionSchema } from './session.schema.js'
|
|
|
17
17
|
import { webhookSchema } from './webhook.schema.js'
|
|
18
18
|
import { workflowSchema } from './workflow.schema.js'
|
|
19
19
|
import { virtualUserSchema } from './virtual-user.schema.js'
|
|
20
|
+
import { virtualUserScheduleSchema } from './virtual-user-schedule.schema.js'
|
|
20
21
|
|
|
21
22
|
export type {
|
|
22
23
|
PikkuSchema,
|
|
@@ -59,6 +60,7 @@ export const pikkuSchemas: PikkuSchema[] = [
|
|
|
59
60
|
scopeSchema,
|
|
60
61
|
auditSchema,
|
|
61
62
|
virtualUserSchema,
|
|
63
|
+
virtualUserScheduleSchema,
|
|
62
64
|
]
|
|
63
65
|
|
|
64
66
|
export { agentSchema } from './agent.schema.js'
|
|
@@ -70,6 +72,7 @@ export { scopeSchema } from './scope.schema.js'
|
|
|
70
72
|
export { secretSchema } from './secret.schema.js'
|
|
71
73
|
export { sessionSchema } from './session.schema.js'
|
|
72
74
|
export { virtualUserSchema } from './virtual-user.schema.js'
|
|
75
|
+
export { virtualUserScheduleSchema } from './virtual-user-schedule.schema.js'
|
|
73
76
|
export { webhookSchema } from './webhook.schema.js'
|
|
74
77
|
export { workflowSchema } from './workflow.schema.js'
|
|
75
78
|
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { PikkuSchema } from './pikku-schema.types.js'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The `virtualUserSchedule` table {@link KyselyVirtualUserScheduleStore} writes
|
|
5
|
+
* to: one row per persona that keeps using the app on its own.
|
|
6
|
+
*
|
|
7
|
+
* Its own schema rather than a third table in {@link virtualUserSchema}, and
|
|
8
|
+
* owned by its own store: recording what a persona did and deciding that it
|
|
9
|
+
* keeps going on its own are separate opt-ins, and a project that only wants
|
|
10
|
+
* the first should not be handed a cadence table it never writes to.
|
|
11
|
+
*
|
|
12
|
+
* `persona` is the primary key: a persona has one cadence, and a second row for
|
|
13
|
+
* the same one would mean two copies of the same user acting at once — not a
|
|
14
|
+
* heavier test, a different and unreproducible one.
|
|
15
|
+
*/
|
|
16
|
+
export const virtualUserScheduleSchema: PikkuSchema = {
|
|
17
|
+
name: 'virtual-user-schedule',
|
|
18
|
+
ownedBy: ['virtualUserScheduleStore'],
|
|
19
|
+
statements: [
|
|
20
|
+
(db) =>
|
|
21
|
+
db.schema
|
|
22
|
+
.createTable('virtualUserSchedule')
|
|
23
|
+
.addColumn('persona', 'varchar(255)', (col) => col.primaryKey())
|
|
24
|
+
// 0 or 1 rather than a boolean, matching the run tables: a bare sqlite
|
|
25
|
+
// driver cannot bind one, and `SerializePlugin` is not installed
|
|
26
|
+
// everywhere. Off by default, so creating the table spends nothing.
|
|
27
|
+
.addColumn('enabled', 'integer', (col) => col.defaultTo(0).notNull())
|
|
28
|
+
.addColumn('disposition', 'varchar(50)', (col) => col.notNull())
|
|
29
|
+
.addColumn('goals', 'text', (col) => col.defaultTo('[]').notNull())
|
|
30
|
+
.addColumn('budget', 'text')
|
|
31
|
+
// The gap to the next run is drawn between these rather than fixed, so
|
|
32
|
+
// the persona does not keep an appointment.
|
|
33
|
+
.addColumn('minIntervalMs', 'bigint', (col) => col.notNull())
|
|
34
|
+
.addColumn('maxIntervalMs', 'bigint', (col) => col.notNull())
|
|
35
|
+
// The entire schedule. Written before a run is dispatched, so a tick
|
|
36
|
+
// that dies halfway cannot leave the row due for the next one.
|
|
37
|
+
.addColumn('nextRunAt', 'timestamp', (col) => col.notNull())
|
|
38
|
+
.addColumn('lastRunId', 'varchar(36)')
|
|
39
|
+
.addColumn('lastRunAt', 'timestamp'),
|
|
40
|
+
|
|
41
|
+
// The only read that matters at speed: which rows are due, on every tick.
|
|
42
|
+
(db) =>
|
|
43
|
+
db.schema
|
|
44
|
+
.createIndex('idx_virtual_user_schedule_due')
|
|
45
|
+
.on('virtualUserSchedule')
|
|
46
|
+
.columns(['enabled', 'nextRunAt']),
|
|
47
|
+
],
|
|
48
|
+
}
|
|
@@ -2,7 +2,8 @@ import { sql } from 'kysely'
|
|
|
2
2
|
import type { PikkuSchema } from './pikku-schema.types.js'
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* The `virtualUserRun`
|
|
5
|
+
* The `virtualUserRun` and `virtualUserRunStep` tables
|
|
6
|
+
* {@link KyselyVirtualUserRunStore} writes to.
|
|
6
7
|
*
|
|
7
8
|
* Deliberately not in `pikkuSchemas`: the runtime never needs this table, only
|
|
8
9
|
* a project that runs virtual users does, and creating it for everyone would
|
|
@@ -38,6 +39,10 @@ export const virtualUserSchema: PikkuSchema = {
|
|
|
38
39
|
.addColumn('goals', 'text', (col) => col.defaultTo('[]').notNull())
|
|
39
40
|
.addColumn('memory', 'text', (col) => col.defaultTo('{}').notNull())
|
|
40
41
|
.addColumn('findings', 'text', (col) => col.defaultTo('[]').notNull())
|
|
42
|
+
// What the user set out to do and how far each one got. On the run row
|
|
43
|
+
// rather than beside the steps: there are a handful of them, and every
|
|
44
|
+
// read of the run wants them.
|
|
45
|
+
.addColumn('intents', 'text', (col) => col.defaultTo('[]').notNull())
|
|
41
46
|
// Counts the engine kept: steps, calls, mutations, tokens, elapsed.
|
|
42
47
|
.addColumn('tally', 'text')
|
|
43
48
|
.addColumn('stoppedBy', 'varchar(255)')
|
|
@@ -62,5 +67,40 @@ export const virtualUserSchema: PikkuSchema = {
|
|
|
62
67
|
.createIndex('idx_virtual_user_run_persona')
|
|
63
68
|
.on('virtualUserRun')
|
|
64
69
|
.columns(['persona', 'createdAt']),
|
|
70
|
+
|
|
71
|
+
// One row per turn, kept out of `virtualUserRun` so that listing runs does
|
|
72
|
+
// not drag a budget's worth of transcript along with it. No foreign key
|
|
73
|
+
// onto the run for the same reason `persona` is not one: this table is
|
|
74
|
+
// created by the store at boot on whatever engine the project uses, and a
|
|
75
|
+
// cascade that half the engines enforce differently is worse than the read
|
|
76
|
+
// rule that a step with no run is orphaned.
|
|
77
|
+
(db) =>
|
|
78
|
+
db.schema
|
|
79
|
+
.createTable('virtualUserRunStep')
|
|
80
|
+
.addColumn('runId', 'varchar(36)', (col) => col.notNull())
|
|
81
|
+
// The engine's own step number, which is what the intent records point
|
|
82
|
+
// at — not a surrogate key, so a transcript reads the same after paging.
|
|
83
|
+
// Spelled `stepIndex` because `index` is a reserved word in mysql.
|
|
84
|
+
.addColumn('stepIndex', 'integer', (col) => col.notNull())
|
|
85
|
+
.addColumn('intentId', 'varchar(255)')
|
|
86
|
+
// The action as the engine scheduled it, including the `invalid` shape
|
|
87
|
+
// for a turn the model got wrong — that turn is the interesting one.
|
|
88
|
+
.addColumn('action', 'text', (col) => col.notNull())
|
|
89
|
+
.addColumn('status', 'integer')
|
|
90
|
+
// 0 or 1 rather than a boolean, because a bare sqlite driver cannot
|
|
91
|
+
// bind one at all and `SerializePlugin` is not installed everywhere.
|
|
92
|
+
.addColumn('ok', 'integer')
|
|
93
|
+
// Already truncated by the engine, and stored JSON-encoded rather than
|
|
94
|
+
// raw: a truncated API response usually starts with a brace, which
|
|
95
|
+
// `SerializePlugin` would read back as an object rather than the string
|
|
96
|
+
// the engine actually saw.
|
|
97
|
+
.addColumn('response', 'text')
|
|
98
|
+
.addColumn('findingKinds', 'text')
|
|
99
|
+
.addColumn('tokensIn', 'integer', (col) => col.defaultTo(0).notNull())
|
|
100
|
+
.addColumn('tokensOut', 'integer', (col) => col.defaultTo(0).notNull())
|
|
101
|
+
.addPrimaryKeyConstraint('pk_virtual_user_run_step', [
|
|
102
|
+
'runId',
|
|
103
|
+
'stepIndex',
|
|
104
|
+
]),
|
|
65
105
|
],
|
|
66
106
|
}
|