@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.
@@ -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/ecosystem/workflow'
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'
@@ -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` table {@link KyselyVirtualUserRunStore} writes to.
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
  }