@pikku/kysely 0.13.4 → 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.
- package/CHANGELOG.md +106 -0
- package/LICENSE +21 -0
- package/README.md +29 -2
- package/dist/src/kysely-tables.d.ts +8 -0
- package/dist/src/kysely-workflow-mirror.js +37 -25
- package/dist/src/kysely-workflow-run-service.d.ts +0 -5
- package/dist/src/kysely-workflow-run-service.js +4 -16
- package/dist/src/kysely-workflow-service.d.ts +35 -6
- package/dist/src/kysely-workflow-service.js +194 -134
- package/dist/src/schema/index.d.ts +17 -0
- package/dist/src/schema/index.js +96 -9
- package/dist/src/schema/workflow.schema.d.ts +4 -2
- package/dist/src/schema/workflow.schema.js +28 -2
- package/package.json +3 -2
- package/src/kysely-tables.ts +8 -0
- package/src/kysely-workflow-mirror.test.ts +1 -13
- package/src/kysely-workflow-mirror.ts +44 -20
- package/src/kysely-workflow-run-service.ts +7 -20
- package/src/kysely-workflow-service.test.ts +431 -0
- package/src/kysely-workflow-service.ts +212 -161
- package/src/schema/index.ts +138 -13
- package/src/schema/schema.test.ts +183 -0
- package/src/schema/workflow.schema.ts +38 -2
|
@@ -10,7 +10,7 @@ import {
|
|
|
10
10
|
type WorkflowStatus,
|
|
11
11
|
type WorkflowVersionStatus,
|
|
12
12
|
} from '@pikku/core/workflow'
|
|
13
|
-
import type
|
|
13
|
+
import { sql, type Kysely } from 'kysely'
|
|
14
14
|
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
15
15
|
import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js'
|
|
16
16
|
import { parseJson } from './kysely-json.js'
|
|
@@ -112,12 +112,13 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
112
112
|
retries: stepOptions?.retries ?? null,
|
|
113
113
|
retryDelay: stepOptions?.retryDelay?.toString() ?? null,
|
|
114
114
|
fromStepName: fromStepName ?? null,
|
|
115
|
+
currentAttempt: 1,
|
|
115
116
|
createdAt: now,
|
|
116
117
|
updatedAt: now,
|
|
117
118
|
})
|
|
118
119
|
.execute()
|
|
119
120
|
|
|
120
|
-
await this.insertHistoryRecord(stepId, 'pending')
|
|
121
|
+
await this.insertHistoryRecord(stepId, 'pending', 1)
|
|
121
122
|
|
|
122
123
|
return {
|
|
123
124
|
stepId,
|
|
@@ -135,31 +136,24 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
135
136
|
|
|
136
137
|
async getStepState(runId: string, stepName: string): Promise<StepState> {
|
|
137
138
|
const row = await this.db
|
|
138
|
-
.selectFrom('workflowStep
|
|
139
|
+
.selectFrom('workflowStep')
|
|
139
140
|
.select([
|
|
140
|
-
'
|
|
141
|
-
'
|
|
142
|
-
'
|
|
143
|
-
'
|
|
144
|
-
'
|
|
145
|
-
'
|
|
146
|
-
'
|
|
147
|
-
|
|
148
|
-
|
|
141
|
+
'workflowStepId',
|
|
142
|
+
'status',
|
|
143
|
+
'result',
|
|
144
|
+
'error',
|
|
145
|
+
'retries',
|
|
146
|
+
'retryDelay',
|
|
147
|
+
'fromStepName',
|
|
148
|
+
// `current_attempt` is bumped alongside every history insert, so it is
|
|
149
|
+
// already the count this used to aggregate — and reading it keeps the
|
|
150
|
+
// engine's hottest query off a table that grows for the life of the run.
|
|
151
|
+
'currentAttempt',
|
|
152
|
+
'createdAt',
|
|
153
|
+
'updatedAt',
|
|
149
154
|
])
|
|
150
|
-
.
|
|
151
|
-
|
|
152
|
-
.selectFrom('workflowStepHistory')
|
|
153
|
-
.select(eb.fn.countAll<number>().as('cnt'))
|
|
154
|
-
.whereRef(
|
|
155
|
-
'workflowStepHistory.workflowStepId',
|
|
156
|
-
'=',
|
|
157
|
-
's.workflowStepId'
|
|
158
|
-
)
|
|
159
|
-
.as('attemptCount')
|
|
160
|
-
)
|
|
161
|
-
.where('s.workflowRunId', '=', runId)
|
|
162
|
-
.where('s.stepName', '=', stepName)
|
|
155
|
+
.where('workflowRunId', '=', runId)
|
|
156
|
+
.where('stepName', '=', stepName)
|
|
163
157
|
.executeTakeFirst()
|
|
164
158
|
|
|
165
159
|
if (!row) {
|
|
@@ -173,7 +167,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
173
167
|
status: row.status as StepState['status'],
|
|
174
168
|
result: parseJson(row.result),
|
|
175
169
|
error: parseJson(row.error),
|
|
176
|
-
attemptCount: Number(row.
|
|
170
|
+
attemptCount: Number(row.currentAttempt ?? 1),
|
|
177
171
|
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
178
172
|
retryDelay: row.retryDelay ?? undefined,
|
|
179
173
|
fromStepName: row.fromStepName ?? undefined,
|
|
@@ -182,6 +176,42 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
182
176
|
}
|
|
183
177
|
}
|
|
184
178
|
|
|
179
|
+
protected override async listStepStates(
|
|
180
|
+
runId: string
|
|
181
|
+
): Promise<Array<StepState & { stepName: string }>> {
|
|
182
|
+
const rows = await this.db
|
|
183
|
+
.selectFrom('workflowStep')
|
|
184
|
+
.select([
|
|
185
|
+
'workflowStepId',
|
|
186
|
+
'stepName',
|
|
187
|
+
'status',
|
|
188
|
+
'result',
|
|
189
|
+
'error',
|
|
190
|
+
'retries',
|
|
191
|
+
'retryDelay',
|
|
192
|
+
'fromStepName',
|
|
193
|
+
'currentAttempt',
|
|
194
|
+
'createdAt',
|
|
195
|
+
'updatedAt',
|
|
196
|
+
])
|
|
197
|
+
.where('workflowRunId', '=', runId)
|
|
198
|
+
.execute()
|
|
199
|
+
|
|
200
|
+
return rows.map((row) => ({
|
|
201
|
+
stepId: row.workflowStepId,
|
|
202
|
+
stepName: row.stepName,
|
|
203
|
+
status: row.status as StepState['status'],
|
|
204
|
+
result: parseJson(row.result),
|
|
205
|
+
error: parseJson(row.error),
|
|
206
|
+
attemptCount: Number(row.currentAttempt ?? 1),
|
|
207
|
+
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
208
|
+
retryDelay: row.retryDelay ?? undefined,
|
|
209
|
+
fromStepName: row.fromStepName ?? undefined,
|
|
210
|
+
createdAt: new Date(row.createdAt),
|
|
211
|
+
updatedAt: new Date(row.updatedAt),
|
|
212
|
+
}))
|
|
213
|
+
}
|
|
214
|
+
|
|
185
215
|
async getRunHistory(
|
|
186
216
|
runId: string
|
|
187
217
|
): Promise<Array<StepState & { stepName: string }>> {
|
|
@@ -189,27 +219,9 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
189
219
|
}
|
|
190
220
|
|
|
191
221
|
protected async setStepRunningImpl(stepId: string): Promise<void> {
|
|
192
|
-
await this.
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
.where('workflowStepId', '=', stepId)
|
|
196
|
-
.execute()
|
|
197
|
-
|
|
198
|
-
const latestHistory = await this.db
|
|
199
|
-
.selectFrom('workflowStepHistory')
|
|
200
|
-
.select('historyId')
|
|
201
|
-
.where('workflowStepId', '=', stepId)
|
|
202
|
-
.orderBy('createdAt', 'desc')
|
|
203
|
-
.limit(1)
|
|
204
|
-
.executeTakeFirst()
|
|
205
|
-
|
|
206
|
-
if (latestHistory) {
|
|
207
|
-
await this.db
|
|
208
|
-
.updateTable('workflowStepHistory')
|
|
209
|
-
.set({ status: 'running', runningAt: new Date() })
|
|
210
|
-
.where('historyId', '=', latestHistory.historyId)
|
|
211
|
-
.execute()
|
|
212
|
-
}
|
|
222
|
+
await this.writeStepTransition(stepId, 'running', {
|
|
223
|
+
runningAt: new Date(),
|
|
224
|
+
})
|
|
213
225
|
}
|
|
214
226
|
|
|
215
227
|
protected async setStepScheduledImpl(stepId: string): Promise<void> {
|
|
@@ -220,9 +232,88 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
220
232
|
.execute()
|
|
221
233
|
}
|
|
222
234
|
|
|
235
|
+
/**
|
|
236
|
+
* Move a step and its current history attempt to the same status in one
|
|
237
|
+
* transaction.
|
|
238
|
+
*
|
|
239
|
+
* Both halves matter: a crash between them used to leave the step row saying
|
|
240
|
+
* `succeeded` while its history row still said `running`, which silently
|
|
241
|
+
* corrupts `getRunHistory` and every timeline reconstruction built on it.
|
|
242
|
+
* The history row is targeted by its `attempt` rather than by a `LIMIT 1`
|
|
243
|
+
* over `created_at`, so a retry that lands in the same millisecond as the
|
|
244
|
+
* attempt it replaces still resolves the newer row.
|
|
245
|
+
*/
|
|
246
|
+
private async writeStepTransition(
|
|
247
|
+
stepId: string,
|
|
248
|
+
status: StepStatus,
|
|
249
|
+
historyValues: Record<string, unknown>
|
|
250
|
+
): Promise<void> {
|
|
251
|
+
const now = new Date()
|
|
252
|
+
const stepValues: Record<string, unknown> = { status, updatedAt: now }
|
|
253
|
+
if ('result' in historyValues) stepValues.result = historyValues.result
|
|
254
|
+
if ('error' in historyValues) stepValues.error = historyValues.error
|
|
255
|
+
// A step carries only its latest outcome, so the other half is cleared.
|
|
256
|
+
if (status === 'succeeded') stepValues.error = null
|
|
257
|
+
if (status === 'failed') stepValues.result = null
|
|
258
|
+
|
|
259
|
+
await this.db.transaction().execute(async (trx) => {
|
|
260
|
+
await trx
|
|
261
|
+
.updateTable('workflowStep')
|
|
262
|
+
.set({
|
|
263
|
+
...stepValues,
|
|
264
|
+
// A step that somehow reaches a transition without a live attempt
|
|
265
|
+
// addresses no history row at all, so give it one here. Written as an
|
|
266
|
+
// expression over the row's own column rather than a subquery, which
|
|
267
|
+
// MySQL would reject against the table being updated.
|
|
268
|
+
currentAttempt: sql`coalesce(current_attempt, 1)`,
|
|
269
|
+
} as any)
|
|
270
|
+
.where('workflowStepId', '=', stepId)
|
|
271
|
+
.execute()
|
|
272
|
+
|
|
273
|
+
const written = await trx
|
|
274
|
+
.updateTable('workflowStepHistory')
|
|
275
|
+
.set({ status, ...historyValues } as any)
|
|
276
|
+
.where('workflowStepId', '=', stepId)
|
|
277
|
+
// Correlate through the step row rather than a MAX() over this table:
|
|
278
|
+
// MySQL rejects a subquery that reads the table being updated, and a
|
|
279
|
+
// primary-key lookup is cheaper than an aggregate anyway.
|
|
280
|
+
.where('attempt', '=', (eb) =>
|
|
281
|
+
eb
|
|
282
|
+
.selectFrom('workflowStep')
|
|
283
|
+
.select('currentAttempt')
|
|
284
|
+
.where('workflowStepId', '=', stepId)
|
|
285
|
+
)
|
|
286
|
+
.executeTakeFirst()
|
|
287
|
+
|
|
288
|
+
// Matching nothing means the attempt has no history row to move, which
|
|
289
|
+
// would commit the step's new status with no record of it — precisely
|
|
290
|
+
// the divergence this transaction exists to prevent. Write the missing
|
|
291
|
+
// row instead of letting the pair drift apart.
|
|
292
|
+
if (Number(written?.numUpdatedRows ?? 0n) === 0) {
|
|
293
|
+
const step = await trx
|
|
294
|
+
.selectFrom('workflowStep')
|
|
295
|
+
.select('currentAttempt')
|
|
296
|
+
.where('workflowStepId', '=', stepId)
|
|
297
|
+
.executeTakeFirst()
|
|
298
|
+
await trx
|
|
299
|
+
.insertInto('workflowStepHistory')
|
|
300
|
+
.values({
|
|
301
|
+
historyId: crypto.randomUUID(),
|
|
302
|
+
workflowStepId: stepId,
|
|
303
|
+
status,
|
|
304
|
+
attempt: step?.currentAttempt ?? 1,
|
|
305
|
+
createdAt: now,
|
|
306
|
+
...historyValues,
|
|
307
|
+
} as any)
|
|
308
|
+
.execute()
|
|
309
|
+
}
|
|
310
|
+
})
|
|
311
|
+
}
|
|
312
|
+
|
|
223
313
|
private async insertHistoryRecord(
|
|
224
314
|
stepId: string,
|
|
225
315
|
status: string,
|
|
316
|
+
attempt: number,
|
|
226
317
|
result?: any,
|
|
227
318
|
error?: SerializedError
|
|
228
319
|
): Promise<void> {
|
|
@@ -231,6 +322,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
231
322
|
historyId: crypto.randomUUID(),
|
|
232
323
|
workflowStepId: stepId,
|
|
233
324
|
status,
|
|
325
|
+
attempt,
|
|
234
326
|
result: result != null ? JSON.stringify(result) : null,
|
|
235
327
|
error: error != null ? JSON.stringify(error) : null,
|
|
236
328
|
createdAt: now,
|
|
@@ -280,38 +372,10 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
280
372
|
stepId: string,
|
|
281
373
|
result: any
|
|
282
374
|
): Promise<void> {
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
.set({
|
|
288
|
-
status: 'succeeded',
|
|
289
|
-
result: resultJson,
|
|
290
|
-
error: null,
|
|
291
|
-
updatedAt: new Date(),
|
|
292
|
-
})
|
|
293
|
-
.where('workflowStepId', '=', stepId)
|
|
294
|
-
.execute()
|
|
295
|
-
|
|
296
|
-
const latestHistory = await this.db
|
|
297
|
-
.selectFrom('workflowStepHistory')
|
|
298
|
-
.select('historyId')
|
|
299
|
-
.where('workflowStepId', '=', stepId)
|
|
300
|
-
.orderBy('createdAt', 'desc')
|
|
301
|
-
.limit(1)
|
|
302
|
-
.executeTakeFirst()
|
|
303
|
-
|
|
304
|
-
if (latestHistory) {
|
|
305
|
-
await this.db
|
|
306
|
-
.updateTable('workflowStepHistory')
|
|
307
|
-
.set({
|
|
308
|
-
status: 'succeeded',
|
|
309
|
-
result: resultJson,
|
|
310
|
-
succeededAt: new Date(),
|
|
311
|
-
})
|
|
312
|
-
.where('historyId', '=', latestHistory.historyId)
|
|
313
|
-
.execute()
|
|
314
|
-
}
|
|
375
|
+
await this.writeStepTransition(stepId, 'succeeded', {
|
|
376
|
+
result: JSON.stringify(result),
|
|
377
|
+
succeededAt: new Date(),
|
|
378
|
+
})
|
|
315
379
|
}
|
|
316
380
|
|
|
317
381
|
protected async setStepErrorImpl(
|
|
@@ -323,73 +387,54 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
323
387
|
stack: error.stack,
|
|
324
388
|
code: (error as any).code,
|
|
325
389
|
}
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
.set({
|
|
331
|
-
status: 'failed',
|
|
332
|
-
error: errorJson,
|
|
333
|
-
result: null,
|
|
334
|
-
updatedAt: new Date(),
|
|
335
|
-
})
|
|
336
|
-
.where('workflowStepId', '=', stepId)
|
|
337
|
-
.execute()
|
|
338
|
-
|
|
339
|
-
const latestHistory = await this.db
|
|
340
|
-
.selectFrom('workflowStepHistory')
|
|
341
|
-
.select('historyId')
|
|
342
|
-
.where('workflowStepId', '=', stepId)
|
|
343
|
-
.orderBy('createdAt', 'desc')
|
|
344
|
-
.limit(1)
|
|
345
|
-
.executeTakeFirst()
|
|
346
|
-
|
|
347
|
-
if (latestHistory) {
|
|
348
|
-
await this.db
|
|
349
|
-
.updateTable('workflowStepHistory')
|
|
350
|
-
.set({ status: 'failed', error: errorJson, failedAt: new Date() })
|
|
351
|
-
.where('historyId', '=', latestHistory.historyId)
|
|
352
|
-
.execute()
|
|
353
|
-
}
|
|
390
|
+
await this.writeStepTransition(stepId, 'failed', {
|
|
391
|
+
error: JSON.stringify(serializedError),
|
|
392
|
+
failedAt: new Date(),
|
|
393
|
+
})
|
|
354
394
|
}
|
|
355
395
|
|
|
356
396
|
protected async createRetryAttemptImpl(
|
|
357
397
|
stepId: string,
|
|
358
398
|
status: 'pending' | 'running'
|
|
359
399
|
): Promise<StepState> {
|
|
400
|
+
// Read before writing: the new attempt number comes from the history this
|
|
401
|
+
// step already has, and the row it reads is the one returned below.
|
|
402
|
+
const previous = await this.db
|
|
403
|
+
.selectFrom('workflowStep')
|
|
404
|
+
.select('currentAttempt')
|
|
405
|
+
.where('workflowStepId', '=', stepId)
|
|
406
|
+
.executeTakeFirst()
|
|
407
|
+
const attempt = (previous?.currentAttempt ?? 0) + 1
|
|
408
|
+
|
|
360
409
|
await this.db
|
|
361
410
|
.updateTable('workflowStep')
|
|
362
|
-
.set({
|
|
411
|
+
.set({
|
|
412
|
+
status,
|
|
413
|
+
result: null,
|
|
414
|
+
error: null,
|
|
415
|
+
currentAttempt: attempt,
|
|
416
|
+
updatedAt: new Date(),
|
|
417
|
+
})
|
|
363
418
|
.where('workflowStepId', '=', stepId)
|
|
364
419
|
.execute()
|
|
365
420
|
|
|
366
|
-
await this.insertHistoryRecord(stepId, status)
|
|
421
|
+
await this.insertHistoryRecord(stepId, status, attempt)
|
|
367
422
|
|
|
368
423
|
const row = await this.db
|
|
369
|
-
.selectFrom('workflowStep
|
|
424
|
+
.selectFrom('workflowStep')
|
|
370
425
|
.select([
|
|
371
|
-
'
|
|
372
|
-
'
|
|
373
|
-
'
|
|
374
|
-
'
|
|
375
|
-
'
|
|
376
|
-
'
|
|
377
|
-
'
|
|
378
|
-
'
|
|
379
|
-
'
|
|
426
|
+
'workflowStepId',
|
|
427
|
+
'status',
|
|
428
|
+
'result',
|
|
429
|
+
'error',
|
|
430
|
+
'retries',
|
|
431
|
+
'retryDelay',
|
|
432
|
+
'fromStepName',
|
|
433
|
+
'currentAttempt',
|
|
434
|
+
'createdAt',
|
|
435
|
+
'updatedAt',
|
|
380
436
|
])
|
|
381
|
-
.
|
|
382
|
-
eb
|
|
383
|
-
.selectFrom('workflowStepHistory')
|
|
384
|
-
.select(eb.fn.countAll<number>().as('cnt'))
|
|
385
|
-
.whereRef(
|
|
386
|
-
'workflowStepHistory.workflowStepId',
|
|
387
|
-
'=',
|
|
388
|
-
's.workflowStepId'
|
|
389
|
-
)
|
|
390
|
-
.as('attemptCount')
|
|
391
|
-
)
|
|
392
|
-
.where('s.workflowStepId', '=', stepId)
|
|
437
|
+
.where('workflowStepId', '=', stepId)
|
|
393
438
|
.executeTakeFirstOrThrow()
|
|
394
439
|
|
|
395
440
|
return {
|
|
@@ -397,7 +442,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
397
442
|
status: row.status as StepState['status'],
|
|
398
443
|
result: parseJson(row.result),
|
|
399
444
|
error: parseJson(row.error),
|
|
400
|
-
attemptCount: Number(row.
|
|
445
|
+
attemptCount: Number(row.currentAttempt ?? 1),
|
|
401
446
|
retries: row.retries != null ? Number(row.retries) : undefined,
|
|
402
447
|
retryDelay: row.retryDelay ?? undefined,
|
|
403
448
|
fromStepName: row.fromStepName ?? undefined,
|
|
@@ -526,23 +571,48 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
526
571
|
.execute()
|
|
527
572
|
}
|
|
528
573
|
|
|
574
|
+
/**
|
|
575
|
+
* Merge one key into the run's JSON state, as a single expression evaluated
|
|
576
|
+
* by the database.
|
|
577
|
+
*
|
|
578
|
+
* The SQL is dialect-specific, so subclasses override this rather than the
|
|
579
|
+
* caller. The default is the SQLite form; MySQL and Postgres differ in how
|
|
580
|
+
* they cast a JSON literal.
|
|
581
|
+
*
|
|
582
|
+
* @param path - JSON path to the key, already quoted (`$."key"`)
|
|
583
|
+
* @param json - The value as JSON text
|
|
584
|
+
*/
|
|
585
|
+
protected jsonSetState(path: string, json: string) {
|
|
586
|
+
return sql<string>`json_set(coalesce(state, '{}'), ${path}, json(${json}))`
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
/**
|
|
590
|
+
* A JSON path for an arbitrary key. State keys are user-supplied (a graph's
|
|
591
|
+
* `setState` name, an approval's hex-encoded reason), so the key is quoted
|
|
592
|
+
* rather than interpolated bare — an unquoted `$.a.b` would silently address
|
|
593
|
+
* a nested path instead of the key literally called `a.b`.
|
|
594
|
+
*/
|
|
595
|
+
private jsonPathFor(name: string): string {
|
|
596
|
+
return `$.${JSON.stringify(name)}`
|
|
597
|
+
}
|
|
598
|
+
|
|
529
599
|
protected async updateRunStateImpl(
|
|
530
600
|
runId: string,
|
|
531
601
|
name: string,
|
|
532
602
|
value: unknown
|
|
533
603
|
): Promise<void> {
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
.where('workflowRunId', '=', runId)
|
|
538
|
-
.executeTakeFirst()
|
|
539
|
-
|
|
540
|
-
const state: Record<string, unknown> = parseJson(row?.state) ?? {}
|
|
541
|
-
state[name] = value
|
|
542
|
-
|
|
604
|
+
// Merged by the database, not read-modify-written in JS: two graph nodes
|
|
605
|
+
// calling setState concurrently used to race, and the later write silently
|
|
606
|
+
// dropped whichever key the earlier one had added.
|
|
543
607
|
await this.db
|
|
544
608
|
.updateTable('workflowRuns')
|
|
545
|
-
.set({
|
|
609
|
+
.set({
|
|
610
|
+
state: this.jsonSetState(
|
|
611
|
+
this.jsonPathFor(name),
|
|
612
|
+
JSON.stringify(value ?? null)
|
|
613
|
+
),
|
|
614
|
+
updatedAt: new Date(),
|
|
615
|
+
})
|
|
546
616
|
.where('workflowRunId', '=', runId)
|
|
547
617
|
.execute()
|
|
548
618
|
}
|
|
@@ -598,24 +668,5 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
598
668
|
return this.runService.getWorkflowVersion(name, graphHash)
|
|
599
669
|
}
|
|
600
670
|
|
|
601
|
-
async getAIGeneratedWorkflows(
|
|
602
|
-
agentName?: string
|
|
603
|
-
): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>> {
|
|
604
|
-
let query = this.db
|
|
605
|
-
.selectFrom('workflowVersions')
|
|
606
|
-
.select(['workflowName', 'graphHash', 'graph'])
|
|
607
|
-
.where('source', '=', 'dynamic-workflow')
|
|
608
|
-
.where('status', '=', 'active')
|
|
609
|
-
if (agentName) {
|
|
610
|
-
query = query.where('workflowName', 'like', `ai:${agentName}:%`)
|
|
611
|
-
}
|
|
612
|
-
const rows = await query.execute()
|
|
613
|
-
return rows.map((row) => ({
|
|
614
|
-
workflowName: row.workflowName,
|
|
615
|
-
graphHash: row.graphHash,
|
|
616
|
-
graph: typeof row.graph === 'string' ? JSON.parse(row.graph) : row.graph,
|
|
617
|
-
}))
|
|
618
|
-
}
|
|
619
|
-
|
|
620
671
|
async close(): Promise<void> {}
|
|
621
672
|
}
|
package/src/schema/index.ts
CHANGED
|
@@ -143,27 +143,106 @@ export const applyPikkuSchemas = async (
|
|
|
143
143
|
const bound = bind(trx)
|
|
144
144
|
for (const schema of schemas) {
|
|
145
145
|
for (const statement of schema.statements) {
|
|
146
|
-
|
|
146
|
+
const query = statement(bound, types)
|
|
147
|
+
try {
|
|
148
|
+
await query.execute()
|
|
149
|
+
} catch (error) {
|
|
150
|
+
throw explain(error, query.compile().sql, schema)
|
|
151
|
+
}
|
|
147
152
|
}
|
|
148
153
|
}
|
|
149
154
|
})
|
|
150
155
|
}
|
|
151
156
|
|
|
157
|
+
/** `references "app"."workflow_runs"` — a foreign key onto a qualified table. */
|
|
158
|
+
const QUALIFIED_REFERENCE = /references "[^"]+"\."[^"]+"/i
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* sqlite's own words for "you put a dot where a table name goes".
|
|
162
|
+
*
|
|
163
|
+
* Matched rather than a driver's `code`, because every sqlite binding
|
|
164
|
+
* (better-sqlite3, node:sqlite, bun:sqlite, libsql) passes the engine's message
|
|
165
|
+
* through verbatim while disagreeing about how to label it. Postgres phrases the
|
|
166
|
+
* same class of error the other way round — `syntax error at or near "."` — so
|
|
167
|
+
* this cannot fire on it.
|
|
168
|
+
*/
|
|
169
|
+
const SQLITE_REJECTED_QUALIFIER = /near "\.":\s*syntax error/i
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Say what a schema-bound connection did to the DDL, when that is what failed.
|
|
173
|
+
*
|
|
174
|
+
* `withSchema(...)` qualifies foreign key targets along with everything else.
|
|
175
|
+
* Postgres needs that — an unqualified reference resolves against `search_path`,
|
|
176
|
+
* which a schema-bound connection does not control — and sqlite refuses it: a
|
|
177
|
+
* `REFERENCES` clause there takes a bare table name, because a foreign key can
|
|
178
|
+
* only point inside the same database, so there is no qualifier to give. One
|
|
179
|
+
* declaration cannot spell both, and what the engine says about it is
|
|
180
|
+
* `near ".": syntax error`, which names neither the schema nor the cause.
|
|
181
|
+
*
|
|
182
|
+
* Gated on sqlite's exact phrasing as well as the SQL. Postgres compiles the
|
|
183
|
+
* same qualified reference legitimately, and can raise a syntax error of its own
|
|
184
|
+
* on this DDL — a column type resolved from `requires` goes in verbatim — so a
|
|
185
|
+
* looser `/syntax error/` would answer for postgres in sqlite's name.
|
|
186
|
+
*/
|
|
187
|
+
const explain = (error: unknown, sql: string, schema: PikkuSchema): unknown => {
|
|
188
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
189
|
+
if (
|
|
190
|
+
!QUALIFIED_REFERENCE.test(sql) ||
|
|
191
|
+
!SQLITE_REJECTED_QUALIFIER.test(message)
|
|
192
|
+
) {
|
|
193
|
+
return error
|
|
194
|
+
}
|
|
195
|
+
return new Error(
|
|
196
|
+
`The '${schema.name}' schema has a foreign key, and this connection is bound to a schema, ` +
|
|
197
|
+
`so it compiled to a qualified 'references "schema"."table"' that the database rejected. ` +
|
|
198
|
+
'sqlite is the engine that does this: its REFERENCES clause takes a bare table name. ' +
|
|
199
|
+
'Use a connection without withSchema(...) there — sqlite has one schema to be in — ' +
|
|
200
|
+
'or write the schema down as a migration with `pikku db generate` and apply it yourself. ' +
|
|
201
|
+
`The database said: ${message}`,
|
|
202
|
+
{ cause: error }
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A table a schema creates, as the DDL addresses it. */
|
|
207
|
+
export interface DeclaredTable {
|
|
208
|
+
/** Present when the connection is `withSchema(...)`-bound. */
|
|
209
|
+
schema?: string
|
|
210
|
+
name: string
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** How a table reads in an error message: qualified only when it is. */
|
|
214
|
+
const displayName = (table: DeclaredTable): string =>
|
|
215
|
+
table.schema ? `${table.schema}.${table.name}` : table.name
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* `create table "workflow_runs"` and `create table "app"."workflow_runs"`.
|
|
219
|
+
*
|
|
220
|
+
* The schema half is optional and the table is always the LAST quoted
|
|
221
|
+
* identifier — matching the first one instead reads a `withSchema('app')`
|
|
222
|
+
* connection's DDL as a table called `app`, which is no table at all.
|
|
223
|
+
*/
|
|
224
|
+
const CREATE_TABLE =
|
|
225
|
+
/^\s*create table (?:if not exists\s+)?(?:"([^"]+)"\.)?"([^"]+)"/i
|
|
226
|
+
|
|
152
227
|
/**
|
|
153
228
|
* The tables a schema creates, read back out of its own compiled SQL.
|
|
154
229
|
*
|
|
155
230
|
* Derived rather than declared alongside the statements, because a hand-kept
|
|
156
231
|
* list of names is a second source of truth: rename a table in a statement and
|
|
157
232
|
* the list keeps answering for the old one.
|
|
233
|
+
*
|
|
234
|
+
* Compiled against `db` rather than in the abstract, so the answer carries the
|
|
235
|
+
* schema the DDL will actually target — the same binding the lookup has to use.
|
|
158
236
|
*/
|
|
159
|
-
const declaredTables = (
|
|
237
|
+
export const declaredTables = (
|
|
238
|
+
schema: PikkuSchema,
|
|
239
|
+
db: Kysely<any>
|
|
240
|
+
): DeclaredTable[] => {
|
|
160
241
|
const bound = bind(db)
|
|
161
|
-
const tables:
|
|
242
|
+
const tables: DeclaredTable[] = []
|
|
162
243
|
for (const statement of schema.statements) {
|
|
163
|
-
const match =
|
|
164
|
-
|
|
165
|
-
)
|
|
166
|
-
if (match) tables.push(match[1]!)
|
|
244
|
+
const match = CREATE_TABLE.exec(statement(bound, {}).compile().sql)
|
|
245
|
+
if (match) tables.push({ schema: match[1], name: match[2]! })
|
|
167
246
|
}
|
|
168
247
|
return tables
|
|
169
248
|
}
|
|
@@ -171,8 +250,51 @@ const declaredTables = (schema: PikkuSchema, db: Kysely<any>): string[] => {
|
|
|
171
250
|
/** What `ensurePikkuSchema` found when it looked. */
|
|
172
251
|
export type EnsureOutcome = 'present' | 'created'
|
|
173
252
|
|
|
174
|
-
|
|
175
|
-
|
|
253
|
+
/**
|
|
254
|
+
* What `db` already has, indexed both ways.
|
|
255
|
+
*
|
|
256
|
+
* Introspection reports every schema it can see, so the bare name alone cannot
|
|
257
|
+
* answer the question: a `workflow_runs` in some other schema is not the one a
|
|
258
|
+
* `withSchema('app')` connection reads or writes. Qualified declarations are
|
|
259
|
+
* matched on the pair. Unqualified DDL resolves against the connection's
|
|
260
|
+
* search_path, which is not knowable from here, so it falls back to the name.
|
|
261
|
+
*/
|
|
262
|
+
interface ExistingTables {
|
|
263
|
+
qualified: Set<string>
|
|
264
|
+
bare: Set<string>
|
|
265
|
+
/** Whether this engine reports schemas at all. */
|
|
266
|
+
schemaAware: boolean
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const tablesOf = async (db: Kysely<any>): Promise<ExistingTables> => {
|
|
270
|
+
const tables = await bind(db).introspection.getTables()
|
|
271
|
+
return {
|
|
272
|
+
qualified: new Set(
|
|
273
|
+
tables
|
|
274
|
+
.filter((table) => table.schema)
|
|
275
|
+
.map((table) => `${table.schema}.${table.name}`)
|
|
276
|
+
),
|
|
277
|
+
bare: new Set(tables.map((table) => table.name)),
|
|
278
|
+
schemaAware: tables.some((table) => table.schema !== undefined),
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Qualified declarations match on the pair — but only where that pair means
|
|
284
|
+
* something.
|
|
285
|
+
*
|
|
286
|
+
* `TableMetadata.schema` is optional, and sqlite and mysql leave it unset:
|
|
287
|
+
* sqlite has one schema to be in, and mysql calls the thing a database. A
|
|
288
|
+
* `withSchema('main')` connection there declares `main.workflow_runs` while
|
|
289
|
+
* introspection can only offer `workflow_runs`, and holding out for the pair
|
|
290
|
+
* would read every existing table as missing — the same false negative this
|
|
291
|
+
* whole function exists to fix, arrived at from the other side. So when the
|
|
292
|
+
* engine reports no schemas, the bare name is the only name there is.
|
|
293
|
+
*/
|
|
294
|
+
const isPresent = (existing: ExistingTables, table: DeclaredTable): boolean =>
|
|
295
|
+
table.schema && existing.schemaAware
|
|
296
|
+
? existing.qualified.has(`${table.schema}.${table.name}`)
|
|
297
|
+
: existing.bare.has(table.name)
|
|
176
298
|
|
|
177
299
|
/**
|
|
178
300
|
* Make sure one schema's tables are there, creating them only if none are.
|
|
@@ -198,14 +320,17 @@ export const ensurePikkuSchema = async (
|
|
|
198
320
|
): Promise<EnsureOutcome> => {
|
|
199
321
|
const existing = await tablesOf(db)
|
|
200
322
|
const declared = declaredTables(schema, db)
|
|
201
|
-
const missing = declared.filter((table) => !existing
|
|
323
|
+
const missing = declared.filter((table) => !isPresent(existing, table))
|
|
202
324
|
|
|
203
325
|
if (missing.length === 0) return 'present'
|
|
204
326
|
|
|
205
327
|
if (missing.length < declared.length) {
|
|
206
328
|
throw new Error(
|
|
207
|
-
`The '${schema.name}' schema is half applied: ${missing.join(', ')} missing, ` +
|
|
208
|
-
`${declared
|
|
329
|
+
`The '${schema.name}' schema is half applied: ${missing.map(displayName).join(', ')} missing, ` +
|
|
330
|
+
`${declared
|
|
331
|
+
.filter((table) => isPresent(existing, table))
|
|
332
|
+
.map(displayName)
|
|
333
|
+
.join(', ')} already there. ` +
|
|
209
334
|
'Something else owns part of it. Reconcile it with a migration — ' +
|
|
210
335
|
'`pikku db generate` writes the declaration down — rather than creating the rest at boot.'
|
|
211
336
|
)
|
|
@@ -221,7 +346,7 @@ export const ensurePikkuSchema = async (
|
|
|
221
346
|
// a guarantee — a boot race is only fully answered by having run the
|
|
222
347
|
// migration, which is what `pikku db generate` is for.
|
|
223
348
|
const after = await tablesOf(db)
|
|
224
|
-
if (declared.every((table) => after
|
|
349
|
+
if (declared.every((table) => isPresent(after, table))) return 'present'
|
|
225
350
|
throw error
|
|
226
351
|
}
|
|
227
352
|
}
|