@pikku/kysely 0.13.5 → 0.13.6

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.
@@ -114,12 +114,13 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
114
114
  retries: step.retries ?? null,
115
115
  retryDelay: step.retryDelay?.toString() ?? null,
116
116
  fromStepName: step.fromStepName ?? null,
117
+ currentAttempt: 1,
117
118
  createdAt: now,
118
119
  updatedAt: now,
119
120
  })
120
121
  .execute()
121
122
 
122
- await this.insertHistoryRecord(step.stepId, step.status)
123
+ await this.insertHistoryRecord(step.stepId, step.status, 1)
123
124
  }
124
125
 
125
126
  async setStepRunning(stepId: string): Promise<void> {
@@ -178,17 +179,25 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
178
179
  failedStepId: string,
179
180
  newStep: StepState & { stepName: string }
180
181
  ): Promise<void> {
182
+ const previous = await this.db
183
+ .selectFrom('workflowStep')
184
+ .select('currentAttempt')
185
+ .where('workflowStepId', '=', failedStepId)
186
+ .executeTakeFirst()
187
+ const attempt = (previous?.currentAttempt ?? 0) + 1
188
+
181
189
  await this.db
182
190
  .updateTable('workflowStep')
183
191
  .set({
184
192
  status: newStep.status,
185
193
  result: null,
186
194
  error: null,
195
+ currentAttempt: attempt,
187
196
  updatedAt: new Date(),
188
197
  })
189
198
  .where('workflowStepId', '=', failedStepId)
190
199
  .execute()
191
- await this.insertHistoryRecord(failedStepId, newStep.status)
200
+ await this.insertHistoryRecord(failedStepId, newStep.status, attempt)
192
201
  }
193
202
 
194
203
  async setBranchTaken(stepId: string, branchKey: string): Promise<void> {
@@ -274,6 +283,7 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
274
283
  private async insertHistoryRecord(
275
284
  stepId: string,
276
285
  status: string,
286
+ attempt: number,
277
287
  resultJson?: string | null,
278
288
  errorJson?: string | null
279
289
  ): Promise<void> {
@@ -282,6 +292,7 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
282
292
  historyId: crypto.randomUUID(),
283
293
  workflowStepId: stepId,
284
294
  status,
295
+ attempt,
285
296
  result: resultJson ?? null,
286
297
  error: errorJson ?? null,
287
298
  createdAt: now,
@@ -309,27 +320,40 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
309
320
  resultJson?: string,
310
321
  errorJson?: string
311
322
  ): Promise<void> {
312
- const latest = await this.db
313
- .selectFrom('workflowStepHistory')
314
- .select('historyId')
323
+ const update: Record<string, any> = { status }
324
+ if (resultJson !== undefined) update.result = resultJson
325
+ if (errorJson !== undefined) update.error = errorJson
326
+ const tsField = timestampFieldFor(status)
327
+ if (tsField) update[tsField] = new Date()
328
+
329
+ const result = await this.db
330
+ .updateTable('workflowStepHistory')
331
+ .set(update)
315
332
  .where('workflowStepId', '=', stepId)
316
- .orderBy('createdAt', 'desc')
317
- .limit(1)
333
+ // Correlate through the step row rather than sorting this table by
334
+ // createdAt: two attempts can land in the same millisecond, and the
335
+ // step row already knows which attempt is current.
336
+ .where('attempt', '=', (eb) =>
337
+ eb
338
+ .selectFrom('workflowStep')
339
+ .select('currentAttempt')
340
+ .where('workflowStepId', '=', stepId)
341
+ )
318
342
  .executeTakeFirst()
319
343
 
320
- if (latest) {
321
- const update: Record<string, any> = { status }
322
- if (resultJson !== undefined) update.result = resultJson
323
- if (errorJson !== undefined) update.error = errorJson
324
- const tsField = timestampFieldFor(status)
325
- if (tsField) update[tsField] = new Date()
326
- await this.db
327
- .updateTable('workflowStepHistory')
328
- .set(update)
329
- .where('historyId', '=', latest.historyId)
330
- .execute()
331
- } else {
332
- await this.insertHistoryRecord(stepId, status, resultJson, errorJson)
344
+ if (Number(result?.numUpdatedRows ?? 0n) === 0) {
345
+ const step = await this.db
346
+ .selectFrom('workflowStep')
347
+ .select('currentAttempt')
348
+ .where('workflowStepId', '=', stepId)
349
+ .executeTakeFirst()
350
+ await this.insertHistoryRecord(
351
+ stepId,
352
+ status,
353
+ step?.currentAttempt ?? 1,
354
+ resultJson,
355
+ errorJson
356
+ )
333
357
  }
334
358
  }
335
359
  }
@@ -130,7 +130,9 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
130
130
  .select((eb) =>
131
131
  eb
132
132
  .selectFrom('workflowStepHistory')
133
- .select((eb2) => eb2.fn.max('workflowStepHistory.succeededAt').as('m'))
133
+ .select((eb2) =>
134
+ eb2.fn.max('workflowStepHistory.succeededAt').as('m')
135
+ )
134
136
  .whereRef(
135
137
  'workflowStepHistory.workflowStepId',
136
138
  '=',
@@ -198,6 +200,7 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
198
200
  'h.status',
199
201
  'h.result',
200
202
  'h.error',
203
+ 'h.attempt',
201
204
  'h.createdAt',
202
205
  'h.runningAt',
203
206
  'h.scheduledAt',
@@ -205,7 +208,10 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
205
208
  'h.failedAt',
206
209
  ])
207
210
  .where('s.workflowRunId', '=', runId)
211
+ // `attempt` breaks the tie when a retry lands in the same millisecond as
212
+ // the attempt it replaces, which `createdAt` alone cannot order.
208
213
  .orderBy('h.createdAt', 'asc')
214
+ .orderBy('h.attempt', 'asc')
209
215
  .execute()
210
216
 
211
217
  let attemptCounters: Record<string, number> = {}
@@ -270,25 +276,6 @@ export class KyselyWorkflowRunService implements WorkflowRunService {
270
276
  }
271
277
  }
272
278
 
273
- async getAIGeneratedWorkflows(
274
- agentName?: string
275
- ): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>> {
276
- let query = this.db
277
- .selectFrom('workflowVersions')
278
- .select(['workflowName', 'graphHash', 'graph'])
279
- .where('source', '=', 'dynamic-workflow')
280
- .where('status', '=', 'active')
281
- if (agentName) {
282
- query = query.where('workflowName', 'like', `ai:${agentName}:%`)
283
- }
284
- const rows = await query.execute()
285
- return rows.map((row) => ({
286
- workflowName: row.workflowName,
287
- graphHash: row.graphHash,
288
- graph: parseJson(row.graph),
289
- }))
290
- }
291
-
292
279
  async deleteRun(id: string): Promise<boolean> {
293
280
  const result = await this.db
294
281
  .deleteFrom('workflowRuns')
@@ -0,0 +1,431 @@
1
+ import { describe, test, beforeEach, afterEach } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import { CamelCasePlugin, Kysely, SqliteDialect, sql } from 'kysely'
4
+ import Database from 'better-sqlite3'
5
+
6
+ import type { KyselyPikkuDB } from './kysely-tables.js'
7
+ import { SerializePlugin } from './serialize-plugin.js'
8
+ import { KyselyWorkflowService } from './kysely-workflow-service.js'
9
+
10
+ let db: Kysely<KyselyPikkuDB>
11
+ let service: KyselyWorkflowService
12
+ let queries: string[]
13
+
14
+ /**
15
+ * Every statement the service issues, so a test can assert on round-trip count
16
+ * rather than on wall-clock — the only measure that stays stable in CI.
17
+ */
18
+ const createDb = () => {
19
+ queries = []
20
+ return new Kysely<KyselyPikkuDB>({
21
+ dialect: new SqliteDialect({ database: new Database(':memory:') }),
22
+ plugins: [new CamelCasePlugin(), new SerializePlugin()],
23
+ log: (event) => {
24
+ if (event.level === 'query') {
25
+ queries.push(event.query.sql)
26
+ }
27
+ },
28
+ })
29
+ }
30
+
31
+ beforeEach(async () => {
32
+ db = createDb()
33
+ service = new KyselyWorkflowService(db, { wireQueues: false } as any)
34
+ await service.init()
35
+ })
36
+
37
+ afterEach(async () => {
38
+ await db.destroy()
39
+ })
40
+
41
+ const listIndexes = async (table: string): Promise<string[]> => {
42
+ const rows = await sql<{
43
+ name: string
44
+ }>`SELECT name FROM sqlite_master WHERE type = 'index' AND tbl_name = ${table} AND name NOT LIKE 'sqlite_%'`.execute(
45
+ db
46
+ )
47
+ return rows.rows.map((r) => r.name)
48
+ }
49
+
50
+ const indexedColumns = async (indexName: string): Promise<string[]> => {
51
+ const rows = await sql<{
52
+ name: string
53
+ }>`SELECT name FROM pragma_index_info(${indexName})`.execute(db)
54
+ return rows.rows.map((r) => r.name)
55
+ }
56
+
57
+ const seedRun = (name = 'wf') =>
58
+ service.createRun(name, { foo: 1 }, false, 'hash1', {
59
+ type: 'internal',
60
+ } as any)
61
+
62
+ describe('KyselyWorkflowService — schema indexes', () => {
63
+ test('workflow_step_history is indexed by its step, the column every hot query filters on', async () => {
64
+ const indexes = await listIndexes('workflow_step_history')
65
+ assert.notEqual(
66
+ indexes.length,
67
+ 0,
68
+ 'workflow_step_history has no indexes; the attempt-count subquery and the latest-attempt lookup both seq-scan it'
69
+ )
70
+
71
+ const columnsPerIndex = await Promise.all(
72
+ indexes.map((name) => indexedColumns(name))
73
+ )
74
+ assert.ok(
75
+ columnsPerIndex.some((cols) => cols[0] === 'workflow_step_id'),
76
+ `expected an index led by workflow_step_id, got ${JSON.stringify(columnsPerIndex)}`
77
+ )
78
+ })
79
+
80
+ test('the step-history index also orders by created_at, so the latest attempt is a lookup not a sort', async () => {
81
+ const indexes = await listIndexes('workflow_step_history')
82
+ const columnsPerIndex = await Promise.all(
83
+ indexes.map((name) => indexedColumns(name))
84
+ )
85
+ assert.ok(
86
+ columnsPerIndex.some(
87
+ (cols) => cols[0] === 'workflow_step_id' && cols[1] === 'created_at'
88
+ ),
89
+ `expected a (workflow_step_id, created_at) index, got ${JSON.stringify(columnsPerIndex)}`
90
+ )
91
+ })
92
+
93
+ test('workflow_runs is indexed for the status + recency listing', async () => {
94
+ const indexes = await listIndexes('workflow_runs')
95
+ const columnsPerIndex = await Promise.all(
96
+ indexes.map((name) => indexedColumns(name))
97
+ )
98
+ assert.ok(
99
+ columnsPerIndex.some((cols) => cols.includes('status')),
100
+ `expected an index covering status, got ${JSON.stringify(columnsPerIndex)}`
101
+ )
102
+ })
103
+
104
+ test('workflow_step is indexed for the per-run status scan the graph runner does every tick', async () => {
105
+ const indexes = await listIndexes('workflow_step')
106
+ const columnsPerIndex = await Promise.all(
107
+ indexes.map((name) => indexedColumns(name))
108
+ )
109
+ assert.ok(
110
+ columnsPerIndex.some(
111
+ (cols) => cols[0] === 'workflow_run_id' && cols.includes('status')
112
+ ),
113
+ `expected a (workflow_run_id, status) index, got ${JSON.stringify(columnsPerIndex)}`
114
+ )
115
+ })
116
+
117
+ test('workflow_versions is indexed for the version lookup', async () => {
118
+ const indexes = await listIndexes('workflow_versions')
119
+ assert.notEqual(
120
+ indexes.length,
121
+ 0,
122
+ 'workflow_versions is queried on every version-mismatch replay with no index'
123
+ )
124
+ })
125
+
126
+ test('init() is idempotent — a second boot does not throw on existing indexes', async () => {
127
+ // A fresh instance, because `init()` returns at its `initialized` guard on
128
+ // the same one — so calling it twice re-issues no DDL and never exercises
129
+ // the duplicate-index path this is here to cover.
130
+ await new KyselyWorkflowService(db, { wireQueues: false } as any).init()
131
+
132
+ const indexes = await listIndexes('workflow_step_history')
133
+ assert.ok(indexes.length > 0)
134
+ })
135
+ })
136
+
137
+ /**
138
+ * A step transition writes the step row and its history row, and nothing else.
139
+ * BEGIN/COMMIT are excluded: they are the price of writing the pair atomically,
140
+ * and counting them would make the assertion about transaction framing rather
141
+ * than about the work being done.
142
+ */
143
+ const dataStatements = () =>
144
+ queries.filter((q) => !/^\s*(begin|commit|rollback)/i.test(q))
145
+
146
+ describe('KyselyWorkflowService — step transition round-trips', () => {
147
+ const transitions: Array<[string, (stepId: string) => Promise<void>]> = [
148
+ ['setStepRunning', (id) => service.setStepRunning(id)],
149
+ ['setStepResult', (id) => service.setStepResult(id, { ok: true })],
150
+ ['setStepError', (id) => service.setStepError(id, new Error('boom'))],
151
+ ]
152
+
153
+ for (const [name, run] of transitions) {
154
+ test(`${name} writes two rows and reads none`, async () => {
155
+ const runId = await seedRun()
156
+ const step = await service.insertStepState(runId, 's1', 'rpc.fn', {
157
+ x: 1,
158
+ })
159
+
160
+ queries.length = 0
161
+ await run(step.stepId)
162
+
163
+ const statements = dataStatements()
164
+ assert.equal(
165
+ statements.length,
166
+ 2,
167
+ `${name} issued ${statements.length} statements:\n${statements.join('\n')}`
168
+ )
169
+ assert.ok(
170
+ statements.every((q) => /^\s*update/i.test(q)),
171
+ `${name} still reads before it writes — the latest-attempt lookup used to sort an unindexed history table:\n${statements.join('\n')}`
172
+ )
173
+ })
174
+
175
+ test(`${name} writes both rows atomically`, async () => {
176
+ const runId = await seedRun()
177
+ const step = await service.insertStepState(runId, 's1', 'rpc.fn', {
178
+ x: 1,
179
+ })
180
+
181
+ queries.length = 0
182
+ await run(step.stepId)
183
+
184
+ assert.ok(
185
+ /^\s*begin/i.test(queries[0] ?? ''),
186
+ `${name} wrote the step and its history outside a transaction; a crash between them leaves the two disagreeing`
187
+ )
188
+ assert.ok(/^\s*commit/i.test(queries.at(-1) ?? ''))
189
+ })
190
+ }
191
+ })
192
+
193
+ describe('KyselyWorkflowService — step transitions stay consistent', () => {
194
+ test('setStepResult writes the step row and its history row together', async () => {
195
+ const runId = await seedRun()
196
+ const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
197
+ await service.setStepRunning(step.stepId)
198
+ await service.setStepResult(step.stepId, { ok: true })
199
+
200
+ const state = await service.getStepState(runId, 's1')
201
+ assert.equal(state.status, 'succeeded')
202
+ assert.deepEqual(state.result, { ok: true })
203
+
204
+ const history = await service.getRunHistory(runId)
205
+ const latest = history.at(-1)
206
+ assert.equal(
207
+ latest?.status,
208
+ 'succeeded',
209
+ 'the step row says succeeded but its latest history row does not'
210
+ )
211
+ assert.deepEqual(latest?.result, { ok: true })
212
+ assert.ok(latest?.succeededAt, 'succeededAt was not stamped on history')
213
+ })
214
+
215
+ test('setStepError records the failure on both the step and its history', async () => {
216
+ const runId = await seedRun()
217
+ const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
218
+ await service.setStepRunning(step.stepId)
219
+ await service.setStepError(step.stepId, new Error('boom'))
220
+
221
+ const state = await service.getStepState(runId, 's1')
222
+ assert.equal(state.status, 'failed')
223
+ assert.equal(state.error?.message, 'boom')
224
+
225
+ const history = await service.getRunHistory(runId)
226
+ const latest = history.at(-1)
227
+ assert.equal(latest?.status, 'failed')
228
+ assert.equal(latest?.error?.message, 'boom')
229
+ assert.ok(latest?.failedAt, 'failedAt was not stamped on history')
230
+ })
231
+
232
+ test('a same-millisecond retry resolves the retry attempt, not the failed one', async () => {
233
+ const runId = await seedRun()
234
+ const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
235
+ await service.setStepRunning(step.stepId)
236
+ await service.setStepError(step.stepId, new Error('first'))
237
+
238
+ // No delay: both history rows land in the same millisecond, so created_at
239
+ // alone cannot say which is newest.
240
+ const retry = await service.createRetryAttempt(step.stepId, 'running')
241
+ await service.setStepResult(retry.stepId, { ok: true })
242
+
243
+ const history = await service.getRunHistory(runId)
244
+ assert.equal(
245
+ history.length,
246
+ 2,
247
+ 'expected one attempt row plus one retry row'
248
+ )
249
+ assert.deepEqual(
250
+ history.map((h) => h.status),
251
+ ['failed', 'succeeded'],
252
+ `the retry resolved the wrong history row: ${JSON.stringify(
253
+ history.map((h) => h.status)
254
+ )}`
255
+ )
256
+ })
257
+ })
258
+
259
+ describe('KyselyWorkflowService — attempt counting', () => {
260
+ test('attemptCount tracks retries', async () => {
261
+ const runId = await seedRun()
262
+ const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
263
+ assert.equal((await service.getStepState(runId, 's1')).attemptCount, 1)
264
+
265
+ await service.setStepError(step.stepId, new Error('one'))
266
+ await service.createRetryAttempt(step.stepId, 'running')
267
+ assert.equal((await service.getStepState(runId, 's1')).attemptCount, 2)
268
+
269
+ await service.setStepError(step.stepId, new Error('two'))
270
+ const third = await service.createRetryAttempt(step.stepId, 'running')
271
+ assert.equal(third.attemptCount, 3)
272
+ assert.equal((await service.getStepState(runId, 's1')).attemptCount, 3)
273
+ })
274
+
275
+ test('reading a step does not touch the history table', async () => {
276
+ const runId = await seedRun()
277
+ await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
278
+
279
+ queries.length = 0
280
+ await service.getStepState(runId, 's1')
281
+ assert.ok(
282
+ !queries.join('\n').includes('workflow_step_history'),
283
+ `the hottest read in the engine still aggregates over the history table:\n${queries.join('\n')}`
284
+ )
285
+ })
286
+
287
+ test('a replay reads every step of a run in one query', async () => {
288
+ const runId = await seedRun()
289
+ for (let i = 0; i < 6; i++) {
290
+ await service.insertStepState(runId, `s${i}`, 'rpc.fn', { i })
291
+ }
292
+
293
+ queries.length = 0
294
+ const steps = await (service as any).listStepStates(runId)
295
+ assert.equal(steps.length, 6)
296
+ assert.equal(
297
+ queries.length,
298
+ 1,
299
+ `expected one query for the whole run, got ${queries.length}`
300
+ )
301
+ assert.deepEqual(steps.map((s: any) => s.stepName).sort(), [
302
+ 's0',
303
+ 's1',
304
+ 's2',
305
+ 's3',
306
+ 's4',
307
+ 's5',
308
+ ])
309
+ })
310
+ })
311
+
312
+ describe('KyselyWorkflowService — run state writes', () => {
313
+ test('concurrent updateRunState calls for different keys do not clobber each other', async () => {
314
+ const runId = await seedRun('wf2')
315
+
316
+ await Promise.all([
317
+ service.updateRunState(runId, 'alpha', 1),
318
+ service.updateRunState(runId, 'beta', 2),
319
+ service.updateRunState(runId, 'gamma', 3),
320
+ ])
321
+
322
+ assert.deepEqual(
323
+ await service.getRunState(runId),
324
+ { alpha: 1, beta: 2, gamma: 3 },
325
+ 'a concurrent read-modify-write over the whole state blob lost a key'
326
+ )
327
+ })
328
+
329
+ test('updateRunState costs a single statement', async () => {
330
+ const runId = await seedRun('wf3')
331
+ queries.length = 0
332
+ await service.updateRunState(runId, 'alpha', 1)
333
+ assert.ok(
334
+ queries.length <= 1,
335
+ `updateRunState issued ${queries.length} statements:\n${queries.join('\n')}`
336
+ )
337
+ })
338
+
339
+ test('updateRunState still replaces an existing key', async () => {
340
+ const runId = await seedRun('wf4')
341
+ await service.updateRunState(runId, 'alpha', 1)
342
+ await service.updateRunState(runId, 'alpha', { nested: true })
343
+ assert.deepEqual(await service.getRunState(runId), {
344
+ alpha: { nested: true },
345
+ })
346
+ })
347
+
348
+ test('values keep their JSON type instead of becoming strings', async () => {
349
+ const runId = await seedRun('wf5')
350
+
351
+ await service.updateRunState(runId, 'empty', [])
352
+ await service.updateRunState(runId, 'zero', 0)
353
+ await service.updateRunState(runId, 'no', false)
354
+ await service.updateRunState(runId, 'nothing', null)
355
+ await service.updateRunState(runId, 'deep', { a: [1, { b: true }] })
356
+
357
+ assert.deepEqual(await service.getRunState(runId), {
358
+ empty: [],
359
+ zero: 0,
360
+ no: false,
361
+ nothing: null,
362
+ deep: { a: [1, { b: true }] },
363
+ })
364
+ })
365
+
366
+ test('a key containing a dot stays one key', async () => {
367
+ const runId = await seedRun('wf6')
368
+
369
+ await service.updateRunState(runId, 'a.b', 'flat')
370
+
371
+ assert.deepEqual(
372
+ await service.getRunState(runId),
373
+ { 'a.b': 'flat' },
374
+ 'the key was read as a nested path instead of a literal name'
375
+ )
376
+ })
377
+
378
+ test('a key containing a quote does not break the statement', async () => {
379
+ const runId = await seedRun('wf7')
380
+
381
+ await service.updateRunState(runId, 'it\'s "quoted"', 1)
382
+
383
+ assert.deepEqual(await service.getRunState(runId), { 'it\'s "quoted"': 1 })
384
+ })
385
+ })
386
+
387
+ /**
388
+ * A step whose `current_attempt` is NULL addresses no history row, so the
389
+ * history half of a transition silently writes nothing while the step half
390
+ * commits — the divergence the transaction exists to prevent.
391
+ */
392
+ describe('KyselyWorkflowService — a transition that addresses no history row', () => {
393
+ const orphanStep = async () => {
394
+ const runId = await seedRun('wf-orphan')
395
+ const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
396
+ await sql`UPDATE workflow_step SET current_attempt = NULL WHERE workflow_step_id = ${step.stepId}`.execute(
397
+ db
398
+ )
399
+ return { runId, step }
400
+ }
401
+
402
+ test('the history still records the outcome', async () => {
403
+ const { runId, step } = await orphanStep()
404
+
405
+ await service.setStepResult(step.stepId, { ok: true })
406
+
407
+ const history = await service.getRunHistory(runId)
408
+ assert.equal(
409
+ history.at(-1)?.status,
410
+ 'succeeded',
411
+ 'the step row says succeeded but no history row does'
412
+ )
413
+ assert.deepEqual(history.at(-1)?.result, { ok: true })
414
+ })
415
+
416
+ test('the step is repaired, so the next transition lands in place', async () => {
417
+ const { runId, step } = await orphanStep()
418
+
419
+ await service.setStepRunning(step.stepId)
420
+ await service.setStepResult(step.stepId, { ok: true })
421
+
422
+ const history = await service.getRunHistory(runId)
423
+ assert.equal(
424
+ history.length,
425
+ 1,
426
+ 'each transition appended a row instead of updating the one already there'
427
+ )
428
+ assert.equal(history[0]!.status, 'succeeded')
429
+ })
430
+ })
431
+