@pikku/kysely 0.12.10 → 0.12.12

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,254 @@
1
+ import type { AuditLog } from '@pikku/core'
2
+ import type {
3
+ Kysely,
4
+ KyselyPlugin,
5
+ PluginTransformQueryArgs,
6
+ PluginTransformResultArgs,
7
+ QueryResult,
8
+ RootOperationNode,
9
+ UnknownRow,
10
+ } from 'kysely'
11
+
12
+ export interface CreateAuditedKyselyOptions {
13
+ audit: AuditLog
14
+ auditReads?: boolean
15
+ eventType?: string
16
+ transactionId?: string | null
17
+ queryIdPrefix?: string
18
+ }
19
+
20
+ type AuditedQuery = {
21
+ queryKind: 'select' | 'insert' | 'update' | 'delete'
22
+ queryId: string
23
+ tables: string[]
24
+ changedColumns?: string[]
25
+ changes?: unknown
26
+ }
27
+
28
+ const QUERY_KIND_BY_NODE: Record<
29
+ string,
30
+ AuditedQuery['queryKind'] | undefined
31
+ > = {
32
+ SelectQueryNode: 'select',
33
+ InsertQueryNode: 'insert',
34
+ UpdateQueryNode: 'update',
35
+ DeleteQueryNode: 'delete',
36
+ }
37
+
38
+ function getIdentifierName(node: unknown): string | undefined {
39
+ if (!node || typeof node !== 'object') return undefined
40
+ const candidate = node as {
41
+ name?: string
42
+ column?: { name?: string }
43
+ identifier?: { name?: string }
44
+ table?: { identifier?: { name?: string } }
45
+ }
46
+ return (
47
+ candidate.name ??
48
+ candidate.column?.name ??
49
+ candidate.identifier?.name ??
50
+ candidate.table?.identifier?.name
51
+ )
52
+ }
53
+
54
+ function collectTableNames(node: unknown, out: Set<string>): void {
55
+ if (!node || typeof node !== 'object') return
56
+
57
+ const candidate = node as {
58
+ kind?: string
59
+ table?: { identifier?: { name?: string } }
60
+ }
61
+
62
+ if (
63
+ candidate.kind === 'TableNode' &&
64
+ typeof candidate.table?.identifier?.name === 'string'
65
+ ) {
66
+ out.add(candidate.table.identifier.name)
67
+ }
68
+
69
+ for (const value of Object.values(node as Record<string, unknown>)) {
70
+ if (Array.isArray(value)) {
71
+ for (const item of value) collectTableNames(item, out)
72
+ continue
73
+ }
74
+ collectTableNames(value, out)
75
+ }
76
+ }
77
+
78
+ function extractValue(node: unknown): unknown {
79
+ if (!node || typeof node !== 'object') return node
80
+
81
+ const candidate = node as {
82
+ kind?: string
83
+ value?: unknown
84
+ values?: unknown[]
85
+ left?: unknown
86
+ right?: unknown
87
+ }
88
+
89
+ switch (candidate.kind) {
90
+ case 'ValueNode':
91
+ return candidate.value
92
+ case 'PrimitiveValueListNode':
93
+ case 'ValueListNode':
94
+ return (candidate.values ?? []).map(extractValue)
95
+ case 'DefaultInsertValueNode':
96
+ return '[default]'
97
+ case 'ColumnNode':
98
+ case 'ReferenceNode':
99
+ return `[${candidate.kind}]`
100
+ case 'RawNode':
101
+ return '[raw]'
102
+ case 'SelectQueryNode':
103
+ return '[subquery]'
104
+ case 'ParensNode':
105
+ return extractValue((candidate as { node?: unknown }).node)
106
+ case 'BinaryOperationNode':
107
+ return {
108
+ left: extractValue(candidate.left),
109
+ right: extractValue(candidate.right),
110
+ }
111
+ default:
112
+ return `[${candidate.kind ?? 'expression'}]`
113
+ }
114
+ }
115
+
116
+ function extractInsertChanges(node: any): {
117
+ changedColumns?: string[]
118
+ changes?: unknown
119
+ } {
120
+ const columns = Array.isArray(node.columns)
121
+ ? node.columns
122
+ .map((column: unknown) => getIdentifierName(column))
123
+ .filter(Boolean)
124
+ : []
125
+ const values = Array.isArray(node.values?.values) ? node.values.values : []
126
+ if (columns.length === 0 || values.length === 0) {
127
+ return {
128
+ changedColumns: columns.length > 0 ? columns : undefined,
129
+ }
130
+ }
131
+
132
+ const rows = values.map((row: any) => {
133
+ const rowValues = Array.isArray(row?.values)
134
+ ? row.values.map(extractValue)
135
+ : []
136
+ return Object.fromEntries(
137
+ columns.map((column, index) => [column, rowValues[index]])
138
+ )
139
+ })
140
+
141
+ return {
142
+ changedColumns: columns,
143
+ changes: rows.length === 1 ? rows[0] : rows,
144
+ }
145
+ }
146
+
147
+ function extractUpdateChanges(node: any): {
148
+ changedColumns?: string[]
149
+ changes?: unknown
150
+ } {
151
+ const updates = Array.isArray(node.updates) ? node.updates : []
152
+ const entries = updates
153
+ .map((update: any) => {
154
+ const column = getIdentifierName(update.column)
155
+ if (!column) return null
156
+ return [column, extractValue(update.value)] as const
157
+ })
158
+ .filter(Boolean) as Array<readonly [string, unknown]>
159
+
160
+ if (entries.length === 0) {
161
+ return {}
162
+ }
163
+
164
+ return {
165
+ changedColumns: entries.map(([column]) => column),
166
+ changes: Object.fromEntries(entries),
167
+ }
168
+ }
169
+
170
+ function extractChangeSet(
171
+ node: RootOperationNode,
172
+ queryKind: AuditedQuery['queryKind']
173
+ ) {
174
+ if (queryKind === 'insert') {
175
+ return extractInsertChanges(node as any)
176
+ }
177
+ if (queryKind === 'update') {
178
+ return extractUpdateChanges(node as any)
179
+ }
180
+ return {}
181
+ }
182
+
183
+ class AuditedKyselyPlugin implements KyselyPlugin {
184
+ private readonly pending = new Map<unknown, AuditedQuery>()
185
+ private queryCounter = 0
186
+
187
+ constructor(private readonly options: CreateAuditedKyselyOptions) {}
188
+
189
+ transformQuery(args: PluginTransformQueryArgs): RootOperationNode {
190
+ const queryKind =
191
+ QUERY_KIND_BY_NODE[(args.node as { kind?: string }).kind ?? '']
192
+ if (!queryKind) {
193
+ return args.node
194
+ }
195
+
196
+ if (queryKind === 'select' && this.options.auditReads === false) {
197
+ return args.node
198
+ }
199
+
200
+ const tables = new Set<string>()
201
+ collectTableNames(args.node, tables)
202
+ const queryId = `${this.options.queryIdPrefix ?? 'q'}-${++this.queryCounter}`
203
+ this.pending.set(args.queryId, {
204
+ queryKind,
205
+ queryId,
206
+ tables: [...tables].sort(),
207
+ ...extractChangeSet(args.node, queryKind),
208
+ })
209
+ return args.node
210
+ }
211
+
212
+ async transformResult(
213
+ args: PluginTransformResultArgs
214
+ ): Promise<QueryResult<UnknownRow>> {
215
+ const pending = this.pending.get(args.queryId)
216
+ if (!pending) {
217
+ return args.result
218
+ }
219
+
220
+ this.pending.delete(args.queryId)
221
+ const rawResult = args.result as QueryResult<UnknownRow> & {
222
+ numAffectedRows?: bigint
223
+ numUpdatedOrDeletedRows?: bigint
224
+ }
225
+ const rowCount =
226
+ rawResult.rows.length > 0
227
+ ? rawResult.rows.length
228
+ : Number(
229
+ rawResult.numUpdatedOrDeletedRows ?? rawResult.numAffectedRows ?? 0n
230
+ )
231
+
232
+ await this.options.audit.write({
233
+ type: this.options.eventType ?? 'db.query',
234
+ source: 'auto',
235
+ transactionId: this.options.transactionId ?? null,
236
+ queryId: pending.queryId,
237
+ metadata: {
238
+ queryKind: pending.queryKind,
239
+ tables: pending.tables,
240
+ changedColumns: pending.changedColumns,
241
+ changes: pending.changes,
242
+ rowCount,
243
+ },
244
+ })
245
+ return rawResult
246
+ }
247
+ }
248
+
249
+ export function createAuditedKysely<DB>(
250
+ db: Kysely<DB>,
251
+ options: CreateAuditedKyselyOptions
252
+ ): Kysely<DB> {
253
+ return db.withPlugin(new AuditedKyselyPlugin(options))
254
+ }
package/src/index.ts CHANGED
@@ -2,6 +2,7 @@ export { KyselyChannelStore } from './kysely-channel-store.js'
2
2
  export { KyselyEventHubStore } from './kysely-eventhub-store.js'
3
3
  export { KyselyWorkflowService } from './kysely-workflow-service.js'
4
4
  export { KyselyWorkflowRunService } from './kysely-workflow-run-service.js'
5
+ export { KyselyWorkflowMirror } from './kysely-workflow-mirror.js'
5
6
  export { KyselyDeploymentService } from './kysely-deployment-service.js'
6
7
  export { KyselyAIStorageService } from './kysely-ai-storage-service.js'
7
8
  export { KyselyAgentRunService } from './kysely-ai-agent-run-service.js'
@@ -11,6 +12,10 @@ export type { KyselySecretServiceConfig } from './kysely-secret-service.js'
11
12
  export { KyselyCredentialService } from './kysely-credential-service.js'
12
13
  export type { KyselyCredentialServiceConfig } from './kysely-credential-service.js'
13
14
  export { KyselySessionStore } from './kysely-session-store.js'
15
+ export {
16
+ createAuditedKysely,
17
+ type CreateAuditedKyselyOptions,
18
+ } from './create-audited-kysely.js'
14
19
 
15
20
  export type { KyselyPikkuDB } from './kysely-tables.js'
16
21
  export type { WorkflowRunService } from '@pikku/core/workflow'
@@ -13,10 +13,11 @@ export class KyselyAgentRunService implements AgentRunService {
13
13
 
14
14
  async listThreads(options?: {
15
15
  agentName?: string
16
+ resourceId?: string
16
17
  limit?: number
17
18
  offset?: number
18
19
  }): Promise<AIThread[]> {
19
- const { agentName, limit = 50, offset = 0 } = options ?? {}
20
+ const { agentName, resourceId, limit = 50, offset = 0 } = options ?? {}
20
21
 
21
22
  let query = this.db
22
23
  .selectFrom('aiThreads as t')
@@ -41,6 +42,10 @@ export class KyselyAgentRunService implements AgentRunService {
41
42
  )
42
43
  }
43
44
 
45
+ if (resourceId) {
46
+ query = query.where('t.resourceId', '=', resourceId)
47
+ }
48
+
44
49
  const result = await query
45
50
  .orderBy('t.updatedAt', 'desc')
46
51
  .limit(limit)
@@ -123,7 +123,9 @@ export class KyselyAIRunStateService implements AIRunStateService {
123
123
  try {
124
124
  approvals = JSON.parse(row.pendingApprovals as string)
125
125
  } catch {
126
- console.warn(`Failed to parse pendingApprovals for run ${row.runId}, treating as empty`)
126
+ console.warn(
127
+ `Failed to parse pendingApprovals for run ${row.runId}, treating as empty`
128
+ )
127
129
  }
128
130
  }
129
131
  const filtered = approvals.filter((a) => a.toolCallId !== toolCallId)
@@ -161,7 +163,9 @@ export class KyselyAIRunStateService implements AIRunStateService {
161
163
  try {
162
164
  approvals = JSON.parse((row as any).pendingApprovals)
163
165
  } catch {
164
- console.warn(`Failed to parse pendingApprovals for run ${row.runId}, treating as empty`)
166
+ console.warn(
167
+ `Failed to parse pendingApprovals for run ${row.runId}, treating as empty`
168
+ )
165
169
  }
166
170
  }
167
171
  const approval = approvals.find((a) => a.toolCallId === toolCallId)
@@ -27,6 +27,7 @@ export class KyselyChannelStore extends ChannelStore {
27
27
  )
28
28
  .addColumn('opening_data', 'text', (col) => col.notNull().defaultTo('{}'))
29
29
  .addColumn('pikku_user_id', 'text')
30
+ .addColumn('state', 'text')
30
31
  .addColumn('last_wire', 'timestamp', (col) =>
31
32
  col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
32
33
  )
@@ -106,5 +107,31 @@ export class KyselyChannelStore extends ChannelStore {
106
107
  }
107
108
  }
108
109
 
110
+ public async setState(channelId: string, state: unknown): Promise<void> {
111
+ await this.db
112
+ .updateTable('channels')
113
+ .set({ state: JSON.stringify(state ?? null) })
114
+ .where('channelId', '=', channelId)
115
+ .execute()
116
+ }
117
+
118
+ public async getState(channelId: string): Promise<unknown | undefined> {
119
+ const row = await this.db
120
+ .selectFrom('channels')
121
+ .select(['state'])
122
+ .where('channelId', '=', channelId)
123
+ .executeTakeFirst()
124
+ if (!row || !row.state) return undefined
125
+ return parseJson(row.state) ?? undefined
126
+ }
127
+
128
+ public async clearState(channelId: string): Promise<void> {
129
+ await this.db
130
+ .updateTable('channels')
131
+ .set({ state: null })
132
+ .where('channelId', '=', channelId)
133
+ .execute()
134
+ }
135
+
109
136
  public async close(): Promise<void> {}
110
137
  }
@@ -42,9 +42,7 @@ export class KyselySessionStore implements SessionStore {
42
42
  return undefined
43
43
  }
44
44
 
45
- return (parseJson(row.session) ?? undefined) as
46
- | CoreUserSession
47
- | undefined
45
+ return (parseJson(row.session) ?? undefined) as CoreUserSession | undefined
48
46
  }
49
47
 
50
48
  async set(pikkuUserId: string, session: CoreUserSession): Promise<void> {
@@ -11,6 +11,8 @@ export interface ChannelsTable {
11
11
  createdAt: Generated<Date>
12
12
  openingData: string
13
13
  pikkuUserId: string | null
14
+ /** JSON-serialized per-socket channel state, keyed by channelId. */
15
+ state: string | null
14
16
  lastWire: Generated<Date>
15
17
  }
16
18
 
@@ -29,6 +31,8 @@ export interface WorkflowRunsTable {
29
31
  state: Generated<string>
30
32
  inline: Generated<boolean>
31
33
  graphHash: string | null
34
+ deterministic: Generated<boolean>
35
+ plannedSteps: string | null
32
36
  wire: string | null
33
37
  createdAt: Generated<Date>
34
38
  updatedAt: Generated<Date>
@@ -0,0 +1,246 @@
1
+ import { describe, test, beforeEach, afterEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'
4
+ import { SerializePlugin } from 'kysely-plugin-serialize'
5
+ import Database from 'better-sqlite3'
6
+
7
+ import type { KyselyPikkuDB } from './kysely-tables.js'
8
+ import { KyselyWorkflowMirror } from './kysely-workflow-mirror.js'
9
+ import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js'
10
+
11
+ let db: Kysely<KyselyPikkuDB>
12
+ let mirror: KyselyWorkflowMirror
13
+ let runService: KyselyWorkflowRunService
14
+
15
+ const RUN_ID = 'run-aaaa'
16
+ const STEP_ID = 'step-bbbb'
17
+
18
+ beforeEach(async () => {
19
+ db = new Kysely<KyselyPikkuDB>({
20
+ dialect: new SqliteDialect({ database: new Database(':memory:') }),
21
+ plugins: [new CamelCasePlugin(), new SerializePlugin()],
22
+ })
23
+ mirror = new KyselyWorkflowMirror(db)
24
+ await mirror.init()
25
+ runService = new KyselyWorkflowRunService(db)
26
+ })
27
+
28
+ afterEach(async () => {
29
+ await db.destroy()
30
+ })
31
+
32
+ async function seedRun(opts?: { input?: any; inline?: boolean }) {
33
+ await mirror.createRun(
34
+ RUN_ID,
35
+ 'wf',
36
+ opts?.input ?? { foo: 1 },
37
+ opts?.inline ?? false,
38
+ 'hash1',
39
+ { type: 'do', id: RUN_ID } as any
40
+ )
41
+ }
42
+
43
+ async function seedStep(stepId = STEP_ID, stepName = 'step1') {
44
+ await mirror.insertStepState(RUN_ID, {
45
+ stepId,
46
+ stepName,
47
+ rpcName: 'rpc.fn',
48
+ data: { x: 1 },
49
+ status: 'pending',
50
+ attemptCount: 1,
51
+ createdAt: new Date(),
52
+ updatedAt: new Date(),
53
+ } as any)
54
+ }
55
+
56
+ describe('KyselyWorkflowMirror — runs', () => {
57
+ test('createRun is visible to KyselyWorkflowRunService.getRun', async () => {
58
+ await seedRun({ input: { hello: 'world' } })
59
+ const run = await runService.getRun(RUN_ID)
60
+ assert.ok(run)
61
+ assert.equal(run.workflow, 'wf')
62
+ assert.equal(run.status, 'running')
63
+ assert.deepEqual(run.input, { hello: 'world' })
64
+ assert.equal(run.graphHash, 'hash1')
65
+ })
66
+
67
+ test('updateRunStatus reflects in getRun', async () => {
68
+ await seedRun()
69
+ await mirror.updateRunStatus(RUN_ID, 'completed', { ok: true })
70
+ const run = await runService.getRun(RUN_ID)
71
+ assert.equal(run!.status, 'completed')
72
+ assert.deepEqual(run!.output, { ok: true })
73
+ })
74
+
75
+ test('listRuns surfaces mirrored runs', async () => {
76
+ await seedRun()
77
+ const runs = await runService.listRuns()
78
+ assert.equal(runs.length, 1)
79
+ assert.equal(runs[0]!.id, RUN_ID)
80
+ })
81
+
82
+ test('getDistinctWorkflowNames returns mirrored workflow names', async () => {
83
+ await seedRun()
84
+ const names = await runService.getDistinctWorkflowNames()
85
+ assert.deepEqual(names, ['wf'])
86
+ })
87
+ })
88
+
89
+ describe('KyselyWorkflowMirror — step lifecycle', () => {
90
+ beforeEach(async () => {
91
+ await seedRun()
92
+ })
93
+
94
+ test('insertStepState creates step + initial pending history', async () => {
95
+ await seedStep()
96
+ const steps = await runService.getRunSteps(RUN_ID)
97
+ assert.equal(steps.length, 1)
98
+ assert.equal(steps[0]!.stepName, 'step1')
99
+ assert.equal(steps[0]!.status, 'pending')
100
+
101
+ const history = await runService.getRunHistory(RUN_ID)
102
+ assert.equal(history.length, 1)
103
+ assert.equal(history[0]!.status, 'pending')
104
+ })
105
+
106
+ test('running → succeeded updates the in-place history row', async () => {
107
+ await seedStep()
108
+ await mirror.setStepRunning(STEP_ID)
109
+ await mirror.setStepResult(STEP_ID, { value: 42 })
110
+
111
+ const steps = await runService.getRunSteps(RUN_ID)
112
+ assert.equal(steps[0]!.status, 'succeeded')
113
+ assert.deepEqual(steps[0]!.result, { value: 42 })
114
+
115
+ const history = await runService.getRunHistory(RUN_ID)
116
+ assert.equal(history.length, 1, 'updates collapse onto latest history row')
117
+ assert.equal(history[0]!.status, 'succeeded')
118
+ assert.deepEqual(history[0]!.result, { value: 42 })
119
+ })
120
+
121
+ test('setStepError stores serialized error', async () => {
122
+ await seedStep()
123
+ await mirror.setStepError(STEP_ID, {
124
+ message: 'boom',
125
+ stack: 'stack',
126
+ code: 'EBOOM',
127
+ })
128
+
129
+ const steps = await runService.getRunSteps(RUN_ID)
130
+ assert.equal(steps[0]!.status, 'failed')
131
+ assert.equal(steps[0]!.error?.message, 'boom')
132
+ assert.equal(steps[0]!.error?.code, 'EBOOM')
133
+ })
134
+
135
+ test('createRetryAttempt appends a new history row + clears result/error', async () => {
136
+ await seedStep()
137
+ await mirror.setStepError(STEP_ID, { message: 'first try', code: 'X' })
138
+
139
+ await mirror.createRetryAttempt(STEP_ID, {
140
+ stepId: STEP_ID,
141
+ stepName: 'step1',
142
+ status: 'pending',
143
+ attemptCount: 2,
144
+ createdAt: new Date(),
145
+ updatedAt: new Date(),
146
+ } as any)
147
+
148
+ const steps = await runService.getRunSteps(RUN_ID)
149
+ assert.equal(steps[0]!.status, 'pending')
150
+ assert.equal(steps[0]!.error, undefined)
151
+ assert.equal(steps[0]!.result, undefined)
152
+
153
+ const history = await runService.getRunHistory(RUN_ID)
154
+ // pending (initial — collapsed onto failed) + pending (retry append)
155
+ assert.equal(history.length, 2)
156
+ assert.equal(history[0]!.status, 'failed')
157
+ assert.equal(history[1]!.status, 'pending')
158
+ })
159
+
160
+ test('setStepChildRunId persists', async () => {
161
+ await seedStep()
162
+ await mirror.setStepChildRunId(STEP_ID, 'child-run-1')
163
+ const steps = await runService.getRunSteps(RUN_ID)
164
+ assert.equal((steps[0] as any).childRunId, 'child-run-1')
165
+ })
166
+
167
+ test('setBranchTaken persists the branch key', async () => {
168
+ await seedStep()
169
+ await mirror.setBranchTaken(STEP_ID, 'left')
170
+ const row = await db
171
+ .selectFrom('workflowStep')
172
+ .select('branchTaken')
173
+ .where('workflowStepId', '=', STEP_ID)
174
+ .executeTakeFirst()
175
+ assert.equal(row?.branchTaken, 'left')
176
+ })
177
+ })
178
+
179
+ describe('KyselyWorkflowMirror — run state', () => {
180
+ beforeEach(async () => {
181
+ await seedRun()
182
+ })
183
+
184
+ test('updateRunState merges values', async () => {
185
+ await mirror.updateRunState(RUN_ID, 'counter', 1)
186
+ await mirror.updateRunState(RUN_ID, 'flag', true)
187
+ const row = await db
188
+ .selectFrom('workflowRuns')
189
+ .select('state')
190
+ .where('workflowRunId', '=', RUN_ID)
191
+ .executeTakeFirst()
192
+ const state =
193
+ typeof row?.state === 'string' ? JSON.parse(row.state) : row?.state
194
+ assert.deepEqual(state, { counter: 1, flag: true })
195
+ })
196
+ })
197
+
198
+ describe('KyselyWorkflowMirror — workflow versions', () => {
199
+ test('upsertWorkflowVersion is idempotent on (name, hash)', async () => {
200
+ await mirror.upsertWorkflowVersion('wf', 'h1', { v: 1 }, 'graph')
201
+ await mirror.upsertWorkflowVersion('wf', 'h1', { v: 2 }, 'graph')
202
+ const v = await runService.getWorkflowVersion('wf', 'h1')
203
+ assert.deepEqual(v?.graph, { v: 1 }, 'first write wins (doNothing)')
204
+ })
205
+
206
+ test('updateWorkflowVersionStatus mutates status', async () => {
207
+ await mirror.upsertWorkflowVersion('wf', 'h1', {}, 'graph', 'active')
208
+ await mirror.updateWorkflowVersionStatus('wf', 'h1', 'archived')
209
+ const row = await db
210
+ .selectFrom('workflowVersions')
211
+ .select('status')
212
+ .where('workflowName', '=', 'wf')
213
+ .where('graphHash', '=', 'h1')
214
+ .executeTakeFirst()
215
+ assert.equal(row?.status, 'archived')
216
+ })
217
+
218
+ test('AI-generated workflows surface via getAIGeneratedWorkflows', async () => {
219
+ await mirror.upsertWorkflowVersion(
220
+ 'ai:agent-x:foo',
221
+ 'h1',
222
+ { nodes: [] },
223
+ 'dynamic-workflow'
224
+ )
225
+ const list = await runService.getAIGeneratedWorkflows('agent-x')
226
+ assert.equal(list.length, 1)
227
+ assert.equal(list[0]!.workflowName, 'ai:agent-x:foo')
228
+ })
229
+ })
230
+
231
+ describe('KyselyWorkflowMirror — init', () => {
232
+ test('init is idempotent', async () => {
233
+ await mirror.init()
234
+ await mirror.init()
235
+ // No-op on second call; just shouldn't throw.
236
+ })
237
+
238
+ test('cohabits with KyselyWorkflowService schema (ifNotExists)', async () => {
239
+ // Same DB, schema already created by mirror.init() in beforeEach.
240
+ // If KyselyWorkflowService.init() ran on the same DB, both should
241
+ // succeed because both use ifNotExists. We can't import it without
242
+ // the singleton-services bootstrap, so simulate by re-calling our
243
+ // own init — proves the SQL is idempotent.
244
+ await assert.doesNotReject(mirror.init())
245
+ })
246
+ })