@pikku/kysely 0.13.2 → 0.13.4
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 +56 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/kysely-ai-run-state-service.js +3 -18
- package/dist/src/kysely-ai-storage-service.d.ts +0 -1
- package/dist/src/kysely-ai-storage-service.js +4 -101
- package/dist/src/kysely-channel-store.js +4 -24
- package/dist/src/kysely-credential-service.js +3 -24
- package/dist/src/kysely-deployment-service.d.ts +0 -1
- package/dist/src/kysely-deployment-service.js +4 -48
- package/dist/src/kysely-scope-service.js +4 -54
- package/dist/src/kysely-secret-service.js +3 -21
- package/dist/src/kysely-session-store.js +4 -11
- package/dist/src/kysely-tables.d.ts +2 -0
- package/dist/src/kysely-webhook-service.d.ts +0 -1
- package/dist/src/kysely-webhook-service.js +4 -51
- package/dist/src/kysely-workflow-mirror.d.ts +7 -3
- package/dist/src/kysely-workflow-mirror.js +10 -87
- package/dist/src/kysely-workflow-service.d.ts +2 -2
- package/dist/src/kysely-workflow-service.js +11 -95
- package/dist/src/schema/ai.schema.d.ts +12 -0
- package/dist/src/schema/ai.schema.js +89 -0
- package/dist/src/schema/channel.schema.d.ts +3 -0
- package/dist/src/schema/channel.schema.js +24 -0
- package/dist/src/schema/credential.schema.d.ts +11 -0
- package/dist/src/schema/credential.schema.js +33 -0
- package/dist/src/schema/deployment.schema.d.ts +3 -0
- package/dist/src/schema/deployment.schema.js +32 -0
- package/dist/src/schema/index.d.ts +82 -0
- package/dist/src/schema/index.js +187 -0
- package/dist/src/schema/pikku-schema.types.d.ts +75 -0
- package/dist/src/schema/pikku-schema.types.js +21 -0
- package/dist/src/schema/scope.schema.d.ts +18 -0
- package/dist/src/schema/scope.schema.js +61 -0
- package/dist/src/schema/secret.schema.d.ts +11 -0
- package/dist/src/schema/secret.schema.js +29 -0
- package/dist/src/schema/session.schema.d.ts +3 -0
- package/dist/src/schema/session.schema.js +13 -0
- package/dist/src/schema/webhook.schema.d.ts +3 -0
- package/dist/src/schema/webhook.schema.js +38 -0
- package/dist/src/schema/workflow.schema.d.ts +15 -0
- package/dist/src/schema/workflow.schema.js +86 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/index.ts +18 -0
- package/src/kysely-ai-run-state-service.ts +7 -28
- package/src/kysely-ai-storage-service.ts +4 -152
- package/src/kysely-channel-store.ts +4 -34
- package/src/kysely-credential-service.ts +3 -36
- package/src/kysely-deployment-service.ts +4 -63
- package/src/kysely-scope-service.ts +4 -81
- package/src/kysely-secret-service.ts +6 -33
- package/src/kysely-session-store.ts +4 -18
- package/src/kysely-tables.test.ts +110 -0
- package/src/kysely-tables.ts +2 -0
- package/src/kysely-webhook-service.ts +4 -67
- package/src/kysely-workflow-mirror.test.ts +7 -7
- package/src/kysely-workflow-mirror.ts +10 -109
- package/src/kysely-workflow-service.ts +18 -125
- package/src/schema/ai.schema.ts +140 -0
- package/src/schema/channel.schema.ts +37 -0
- package/src/schema/credential.schema.ts +46 -0
- package/src/schema/deployment.schema.ts +47 -0
- package/src/schema/index.ts +249 -0
- package/src/schema/pikku-schema.types.ts +94 -0
- package/src/schema/schema.test.ts +273 -0
- package/src/schema/scope.schema.ts +95 -0
- package/src/schema/secret.schema.ts +40 -0
- package/src/schema/session.schema.ts +20 -0
- package/src/schema/webhook.schema.ts +57 -0
- package/src/schema/workflow.schema.ts +113 -0
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { test, describe } from 'node:test'
|
|
2
|
+
import assert from 'node:assert/strict'
|
|
3
|
+
import { readFileSync } from 'node:fs'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { Kysely, SqliteDialect } from 'kysely'
|
|
6
|
+
import Database from 'better-sqlite3'
|
|
7
|
+
|
|
8
|
+
import { applyPikkuSchemas } from './schema/index.js'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* `KyselyPikkuDB` and the schema declaration describe the same tables, and
|
|
12
|
+
* nothing but this makes them agree.
|
|
13
|
+
*
|
|
14
|
+
* The types stay hand-written on purpose: they carry knowledge introspection
|
|
15
|
+
* cannot recover — `WorkflowStatus` rather than `string`, `Generated<Date>`
|
|
16
|
+
* rather than `Date`, and the comments explaining what a column holds.
|
|
17
|
+
* Generating them from the DDL would trade all of that for one fewer file.
|
|
18
|
+
*
|
|
19
|
+
* What generating would have bought is drift protection, and that is what these
|
|
20
|
+
* tests buy instead: add a column to a schema and forget the interface, or the
|
|
21
|
+
* reverse, and the names stop matching here.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const SOURCE = readFileSync(
|
|
25
|
+
join(import.meta.dirname, 'kysely-tables.ts'),
|
|
26
|
+
'utf8'
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
/** The same transform `CamelCasePlugin` applies to every declared identifier. */
|
|
30
|
+
const physical = (name: string): string =>
|
|
31
|
+
name.replace(/([a-z0-9])([A-Z])/g, '$1_$2').toLowerCase()
|
|
32
|
+
|
|
33
|
+
const interfaceBody = (name: string): string => {
|
|
34
|
+
const start = SOURCE.indexOf(`export interface ${name} {`)
|
|
35
|
+
assert.notEqual(start, -1, `no interface ${name}`)
|
|
36
|
+
const end = SOURCE.indexOf('\n}', start)
|
|
37
|
+
return SOURCE.slice(SOURCE.indexOf('{', start) + 1, end)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const fieldsOf = (name: string): string[] =>
|
|
41
|
+
interfaceBody(name)
|
|
42
|
+
.split('\n')
|
|
43
|
+
.map((line) => /^ {2}(\w+)(\??):/.exec(line)?.[1])
|
|
44
|
+
.filter((field): field is string => field !== undefined)
|
|
45
|
+
|
|
46
|
+
/** `KyselyPikkuDB`'s own table-name → interface-name mapping. */
|
|
47
|
+
const tableInterfaces = new Map(
|
|
48
|
+
interfaceBody('KyselyPikkuDB')
|
|
49
|
+
.split('\n')
|
|
50
|
+
.map((line) => /^ {2}(\w+): (\w+)$/.exec(line))
|
|
51
|
+
.filter((match): match is RegExpExecArray => match !== null)
|
|
52
|
+
.map((match) => [match[1]!, match[2]!])
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
/** The schema as declared, materialized and read back. */
|
|
56
|
+
const declaredSchema = async (): Promise<Map<string, Set<string>>> => {
|
|
57
|
+
const db = new Kysely<any>({
|
|
58
|
+
dialect: new SqliteDialect({ database: new Database(':memory:') }),
|
|
59
|
+
})
|
|
60
|
+
try {
|
|
61
|
+
// The scope schema's grants reference it, so it has to be there first —
|
|
62
|
+
// the same prerequisite a real project satisfies with Better Auth.
|
|
63
|
+
await db.schema
|
|
64
|
+
.createTable('user')
|
|
65
|
+
.addColumn('id', 'text', (col) => col.primaryKey())
|
|
66
|
+
.execute()
|
|
67
|
+
await applyPikkuSchemas(db)
|
|
68
|
+
|
|
69
|
+
const tables = await db.introspection.getTables()
|
|
70
|
+
return new Map(
|
|
71
|
+
tables
|
|
72
|
+
.filter((table) => table.name !== 'user')
|
|
73
|
+
.map((table) => [
|
|
74
|
+
table.name,
|
|
75
|
+
new Set(table.columns.map((column) => column.name)),
|
|
76
|
+
])
|
|
77
|
+
)
|
|
78
|
+
} finally {
|
|
79
|
+
await db.destroy()
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
describe('KyselyPikkuDB tracks the schema declaration', () => {
|
|
84
|
+
test('names every table the declaration creates, and no others', async () => {
|
|
85
|
+
const declared = await declaredSchema()
|
|
86
|
+
|
|
87
|
+
const typed = new Set([...tableInterfaces.keys()].map(physical))
|
|
88
|
+
assert.deepEqual(
|
|
89
|
+
[...typed].sort(),
|
|
90
|
+
[...declared.keys()].sort(),
|
|
91
|
+
'a table in one and not the other means the two have drifted apart'
|
|
92
|
+
)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
test('names every column the declaration creates, and no others', async () => {
|
|
96
|
+
const declared = await declaredSchema()
|
|
97
|
+
|
|
98
|
+
for (const [key, interfaceName] of tableInterfaces) {
|
|
99
|
+
const table = physical(key)
|
|
100
|
+
const columns = declared.get(table)
|
|
101
|
+
assert.ok(columns, `${table} is declared nowhere`)
|
|
102
|
+
|
|
103
|
+
assert.deepEqual(
|
|
104
|
+
fieldsOf(interfaceName).map(physical).sort(),
|
|
105
|
+
[...columns].sort(),
|
|
106
|
+
`${interfaceName} and the '${table}' declaration disagree`
|
|
107
|
+
)
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
})
|
package/src/kysely-tables.ts
CHANGED
|
@@ -127,6 +127,8 @@ export interface AIRunTable {
|
|
|
127
127
|
errorMessage: string | null
|
|
128
128
|
suspendReason: 'approval' | 'credential' | 'rpc-missing' | null
|
|
129
129
|
missingRpcs: string | null
|
|
130
|
+
/** JSON-serialized approvals the run is suspended on. */
|
|
131
|
+
pendingApprovals: string | null
|
|
130
132
|
usageInputTokens: Generated<number>
|
|
131
133
|
usageOutputTokens: Generated<number>
|
|
132
134
|
usageModel: Generated<string>
|
|
@@ -9,8 +9,9 @@ import {
|
|
|
9
9
|
} from '@pikku/core/services'
|
|
10
10
|
import type { QueueService } from '@pikku/core/queue'
|
|
11
11
|
import type { Kysely } from 'kysely'
|
|
12
|
-
import { sql } from 'kysely'
|
|
13
12
|
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
13
|
+
import { ensurePikkuSchema } from './schema/index.js'
|
|
14
|
+
import { webhookSchema } from './schema/webhook.schema.js'
|
|
14
15
|
|
|
15
16
|
/**
|
|
16
17
|
* Durable {@link QueueWebhookService}: still delivers through the
|
|
@@ -30,59 +31,8 @@ export class KyselyWebhookService extends QueueWebhookService {
|
|
|
30
31
|
}
|
|
31
32
|
|
|
32
33
|
public async init(): Promise<void> {
|
|
33
|
-
if (this.initialized)
|
|
34
|
-
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
await this.db.schema
|
|
38
|
-
.createTable('webhook_delivery')
|
|
39
|
-
.ifNotExists()
|
|
40
|
-
.addColumn('delivery_id', 'text', (col) => col.primaryKey())
|
|
41
|
-
.addColumn('organization_id', 'text')
|
|
42
|
-
.addColumn('url', 'text', (col) => col.notNull())
|
|
43
|
-
.addColumn('event', 'text')
|
|
44
|
-
.addColumn('status', 'text', (col) => col.defaultTo('pending').notNull())
|
|
45
|
-
.addColumn('attempts', 'integer', (col) => col.defaultTo(0).notNull())
|
|
46
|
-
.addColumn('created_at', 'timestamp', (col) =>
|
|
47
|
-
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
48
|
-
)
|
|
49
|
-
.addColumn('updated_at', 'timestamp', (col) =>
|
|
50
|
-
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
51
|
-
)
|
|
52
|
-
.addColumn('delivered_at', 'timestamp')
|
|
53
|
-
.execute()
|
|
54
|
-
|
|
55
|
-
await this.db.schema
|
|
56
|
-
.createTable('webhook_delivery_attempt')
|
|
57
|
-
.ifNotExists()
|
|
58
|
-
.addColumn('attempt_id', 'text', (col) => col.primaryKey())
|
|
59
|
-
.addColumn('delivery_id', 'text', (col) =>
|
|
60
|
-
col.notNull().references('webhook_delivery.delivery_id').onDelete('cascade')
|
|
61
|
-
)
|
|
62
|
-
.addColumn('attempt_number', 'integer', (col) => col.notNull())
|
|
63
|
-
.addColumn('status_code', 'integer')
|
|
64
|
-
.addColumn('response_body', 'text')
|
|
65
|
-
.addColumn('error', 'text')
|
|
66
|
-
.addColumn('created_at', 'timestamp', (col) =>
|
|
67
|
-
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
68
|
-
)
|
|
69
|
-
.execute()
|
|
70
|
-
|
|
71
|
-
await this.createIndexSafe(
|
|
72
|
-
this.db.schema
|
|
73
|
-
.createIndex('idx_webhook_delivery_org')
|
|
74
|
-
.ifNotExists()
|
|
75
|
-
.on('webhook_delivery')
|
|
76
|
-
.column('organization_id')
|
|
77
|
-
)
|
|
78
|
-
await this.createIndexSafe(
|
|
79
|
-
this.db.schema
|
|
80
|
-
.createIndex('idx_webhook_delivery_attempt_delivery')
|
|
81
|
-
.ifNotExists()
|
|
82
|
-
.on('webhook_delivery_attempt')
|
|
83
|
-
.column('delivery_id')
|
|
84
|
-
)
|
|
85
|
-
|
|
34
|
+
if (this.initialized) return
|
|
35
|
+
await ensurePikkuSchema(this.db, webhookSchema)
|
|
86
36
|
this.initialized = true
|
|
87
37
|
}
|
|
88
38
|
|
|
@@ -184,17 +134,4 @@ export class KyselyWebhookService extends QueueWebhookService {
|
|
|
184
134
|
.execute()
|
|
185
135
|
return { delivery, attempts }
|
|
186
136
|
}
|
|
187
|
-
|
|
188
|
-
private async createIndexSafe(builder: {
|
|
189
|
-
execute(): Promise<void>
|
|
190
|
-
}): Promise<void> {
|
|
191
|
-
try {
|
|
192
|
-
await builder.execute()
|
|
193
|
-
} catch (e: any) {
|
|
194
|
-
if (e?.code === 'ER_DUP_KEYNAME' || e?.errno === 1061) return
|
|
195
|
-
if (e?.code === '42P07') return
|
|
196
|
-
if (e?.message?.includes('already exists')) return
|
|
197
|
-
throw e
|
|
198
|
-
}
|
|
199
|
-
}
|
|
200
137
|
}
|
|
@@ -258,12 +258,12 @@ describe('KyselyWorkflowMirror — init', () => {
|
|
|
258
258
|
// No-op on second call; just shouldn't throw.
|
|
259
259
|
})
|
|
260
260
|
|
|
261
|
-
test('cohabits with KyselyWorkflowService
|
|
262
|
-
//
|
|
263
|
-
//
|
|
264
|
-
//
|
|
265
|
-
//
|
|
266
|
-
|
|
267
|
-
await assert.doesNotReject(
|
|
261
|
+
test('cohabits with KyselyWorkflowService, which declares the same tables', async () => {
|
|
262
|
+
// Both services own the workflow schema, so both call init() against the
|
|
263
|
+
// same database. The second one to run must find the tables and stop, not
|
|
264
|
+
// try to create them again. Importing the other service needs the
|
|
265
|
+
// singleton-services bootstrap, so a fresh instance stands in for it.
|
|
266
|
+
const second = new KyselyWorkflowMirror(db)
|
|
267
|
+
await assert.doesNotReject(second.init())
|
|
268
268
|
})
|
|
269
269
|
})
|
|
@@ -8,8 +8,9 @@ import type {
|
|
|
8
8
|
WorkflowVersionStatus,
|
|
9
9
|
} from '@pikku/core/workflow'
|
|
10
10
|
import type { Kysely } from 'kysely'
|
|
11
|
-
import { sql } from 'kysely'
|
|
12
11
|
import type { KyselyPikkuDB } from './kysely-tables.js'
|
|
12
|
+
import { ensurePikkuSchema } from './schema/index.js'
|
|
13
|
+
import { workflowSchema } from './schema/workflow.schema.js'
|
|
13
14
|
|
|
14
15
|
/**
|
|
15
16
|
* Kysely-backed `WorkflowRunMirror`.
|
|
@@ -29,117 +30,17 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
|
|
|
29
30
|
constructor(protected db: Kysely<KyselyPikkuDB>) {}
|
|
30
31
|
|
|
31
32
|
/**
|
|
32
|
-
* Create the underlying tables if
|
|
33
|
-
*
|
|
34
|
-
*
|
|
33
|
+
* Create the underlying tables if none of them exist yet.
|
|
34
|
+
*
|
|
35
|
+
* Safe to call from either the mirror or `KyselyWorkflowService.init()` —
|
|
36
|
+
* both apply the same `workflowSchema`, and `ensurePikkuSchema` is a no-op
|
|
37
|
+
* once the tables are there. A database holding only *part* of the schema
|
|
38
|
+
* throws rather than filling in the rest: something else owns those tables,
|
|
39
|
+
* and boot is not where that gets reconciled.
|
|
35
40
|
*/
|
|
36
41
|
public async init(): Promise<void> {
|
|
37
42
|
if (this.initialized) return
|
|
38
|
-
|
|
39
|
-
await this.db.schema
|
|
40
|
-
.createTable('workflow_runs')
|
|
41
|
-
.ifNotExists()
|
|
42
|
-
.addColumn('workflow_run_id', 'text', (col) => col.primaryKey())
|
|
43
|
-
.addColumn('workflow', 'text', (col) => col.notNull())
|
|
44
|
-
.addColumn('status', 'text', (col) => col.notNull())
|
|
45
|
-
.addColumn('input', 'text', (col) => col.notNull())
|
|
46
|
-
.addColumn('output', 'text')
|
|
47
|
-
.addColumn('error', 'text')
|
|
48
|
-
.addColumn('state', 'text', (col) => col.defaultTo('{}'))
|
|
49
|
-
.addColumn('inline', 'boolean', (col) => col.defaultTo(false))
|
|
50
|
-
.addColumn('graph_hash', 'text')
|
|
51
|
-
.addColumn('deterministic', 'boolean', (col) => col.defaultTo(false))
|
|
52
|
-
.addColumn('planned_steps', 'text')
|
|
53
|
-
.addColumn('wire', 'text')
|
|
54
|
-
.addColumn('created_at', 'timestamp', (col) =>
|
|
55
|
-
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
56
|
-
)
|
|
57
|
-
.addColumn('updated_at', 'timestamp', (col) =>
|
|
58
|
-
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
59
|
-
)
|
|
60
|
-
.execute()
|
|
61
|
-
|
|
62
|
-
await this.db.schema
|
|
63
|
-
.createTable('workflow_step')
|
|
64
|
-
.ifNotExists()
|
|
65
|
-
.addColumn('workflow_step_id', 'text', (col) => col.primaryKey())
|
|
66
|
-
.addColumn('workflow_run_id', 'text', (col) =>
|
|
67
|
-
col
|
|
68
|
-
.notNull()
|
|
69
|
-
.references('workflow_runs.workflow_run_id')
|
|
70
|
-
.onDelete('cascade')
|
|
71
|
-
)
|
|
72
|
-
.addColumn('step_name', 'text', (col) => col.notNull())
|
|
73
|
-
.addColumn('rpc_name', 'text')
|
|
74
|
-
.addColumn('data', 'text')
|
|
75
|
-
.addColumn('status', 'text', (col) => col.notNull().defaultTo('pending'))
|
|
76
|
-
.addColumn('result', 'text')
|
|
77
|
-
.addColumn('error', 'text')
|
|
78
|
-
.addColumn('child_run_id', 'text')
|
|
79
|
-
.addColumn('branch_taken', 'text')
|
|
80
|
-
.addColumn('retries', 'integer')
|
|
81
|
-
.addColumn('retry_delay', 'text')
|
|
82
|
-
.addColumn('from_step_name', 'text')
|
|
83
|
-
.addColumn('created_at', 'timestamp', (col) =>
|
|
84
|
-
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
85
|
-
)
|
|
86
|
-
.addColumn('updated_at', 'timestamp', (col) =>
|
|
87
|
-
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
88
|
-
)
|
|
89
|
-
.addUniqueConstraint('workflow_step_run_name_unique', [
|
|
90
|
-
'workflow_run_id',
|
|
91
|
-
'step_name',
|
|
92
|
-
])
|
|
93
|
-
.execute()
|
|
94
|
-
|
|
95
|
-
// Additive: backfill the column on mirror tables created before from_step_name.
|
|
96
|
-
await this.db.schema
|
|
97
|
-
.alterTable('workflow_step')
|
|
98
|
-
.addColumn('from_step_name', 'text')
|
|
99
|
-
.execute()
|
|
100
|
-
.catch(() => {
|
|
101
|
-
// Column already exists (fresh tables include it above) — nothing to do.
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
await this.db.schema
|
|
105
|
-
.createTable('workflow_step_history')
|
|
106
|
-
.ifNotExists()
|
|
107
|
-
.addColumn('history_id', 'text', (col) => col.primaryKey())
|
|
108
|
-
.addColumn('workflow_step_id', 'text', (col) =>
|
|
109
|
-
col
|
|
110
|
-
.notNull()
|
|
111
|
-
.references('workflow_step.workflow_step_id')
|
|
112
|
-
.onDelete('cascade')
|
|
113
|
-
)
|
|
114
|
-
.addColumn('status', 'text', (col) => col.notNull())
|
|
115
|
-
.addColumn('result', 'text')
|
|
116
|
-
.addColumn('error', 'text')
|
|
117
|
-
.addColumn('created_at', 'timestamp', (col) =>
|
|
118
|
-
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
119
|
-
)
|
|
120
|
-
.addColumn('running_at', 'timestamp')
|
|
121
|
-
.addColumn('scheduled_at', 'timestamp')
|
|
122
|
-
.addColumn('succeeded_at', 'timestamp')
|
|
123
|
-
.addColumn('failed_at', 'timestamp')
|
|
124
|
-
.execute()
|
|
125
|
-
|
|
126
|
-
await this.db.schema
|
|
127
|
-
.createTable('workflow_versions')
|
|
128
|
-
.ifNotExists()
|
|
129
|
-
.addColumn('workflow_name', 'text', (col) => col.notNull())
|
|
130
|
-
.addColumn('graph_hash', 'text', (col) => col.notNull())
|
|
131
|
-
.addColumn('graph', 'text', (col) => col.notNull())
|
|
132
|
-
.addColumn('source', 'text', (col) => col.notNull())
|
|
133
|
-
.addColumn('status', 'text', (col) => col.notNull().defaultTo('active'))
|
|
134
|
-
.addColumn('created_at', 'timestamp', (col) =>
|
|
135
|
-
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
136
|
-
)
|
|
137
|
-
.addPrimaryKeyConstraint('workflow_versions_pk', [
|
|
138
|
-
'workflow_name',
|
|
139
|
-
'graph_hash',
|
|
140
|
-
])
|
|
141
|
-
.execute()
|
|
142
|
-
|
|
43
|
+
await ensurePikkuSchema(this.db, workflowSchema)
|
|
143
44
|
this.initialized = true
|
|
144
45
|
}
|
|
145
46
|
|
|
@@ -2,6 +2,7 @@ import type { SerializedError } from '@pikku/core'
|
|
|
2
2
|
import {
|
|
3
3
|
PikkuWorkflowService,
|
|
4
4
|
type WorkflowPlannedStep,
|
|
5
|
+
type WorkflowQueueOptions,
|
|
5
6
|
type WorkflowRun,
|
|
6
7
|
type WorkflowRunWire,
|
|
7
8
|
type StepState,
|
|
@@ -10,141 +11,27 @@ import {
|
|
|
10
11
|
type WorkflowVersionStatus,
|
|
11
12
|
} from '@pikku/core/workflow'
|
|
12
13
|
import type { Kysely } from 'kysely'
|
|
13
|
-
import { sql } 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'
|
|
17
|
+
import { ensurePikkuSchema } from './schema/index.js'
|
|
18
|
+
import { workflowSchema } from './schema/workflow.schema.js'
|
|
17
19
|
|
|
18
20
|
export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
19
21
|
private initialized = false
|
|
20
22
|
private runService: KyselyWorkflowRunService
|
|
21
23
|
|
|
22
|
-
constructor(
|
|
23
|
-
|
|
24
|
+
constructor(
|
|
25
|
+
protected db: Kysely<KyselyPikkuDB>,
|
|
26
|
+
options: WorkflowQueueOptions = {}
|
|
27
|
+
) {
|
|
28
|
+
super(options)
|
|
24
29
|
this.runService = new KyselyWorkflowRunService(db)
|
|
25
30
|
}
|
|
26
31
|
|
|
27
32
|
public async init(): Promise<void> {
|
|
28
|
-
if (this.initialized)
|
|
29
|
-
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
await this.db.schema
|
|
33
|
-
.createTable('workflow_runs')
|
|
34
|
-
.ifNotExists()
|
|
35
|
-
.addColumn('workflow_run_id', 'text', (col) =>
|
|
36
|
-
col
|
|
37
|
-
.primaryKey()
|
|
38
|
-
.defaultTo(sql`${sql.raw("'" + crypto.randomUUID() + "'")}`)
|
|
39
|
-
)
|
|
40
|
-
.addColumn('workflow', 'text', (col) => col.notNull())
|
|
41
|
-
.addColumn('status', 'text', (col) => col.notNull())
|
|
42
|
-
.addColumn('input', 'text', (col) => col.notNull())
|
|
43
|
-
.addColumn('output', 'text')
|
|
44
|
-
.addColumn('error', 'text')
|
|
45
|
-
.addColumn('state', 'text', (col) => col.defaultTo('{}'))
|
|
46
|
-
.addColumn('inline', 'boolean', (col) => col.defaultTo(false))
|
|
47
|
-
.addColumn('graph_hash', 'text')
|
|
48
|
-
.addColumn('deterministic', 'boolean', (col) => col.defaultTo(false))
|
|
49
|
-
.addColumn('planned_steps', 'text')
|
|
50
|
-
.addColumn('wire', 'text')
|
|
51
|
-
.addColumn('created_at', 'timestamp', (col) =>
|
|
52
|
-
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
53
|
-
)
|
|
54
|
-
.addColumn('updated_at', 'timestamp', (col) =>
|
|
55
|
-
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
56
|
-
)
|
|
57
|
-
.execute()
|
|
58
|
-
|
|
59
|
-
await this.db.schema
|
|
60
|
-
.createTable('workflow_step')
|
|
61
|
-
.ifNotExists()
|
|
62
|
-
.addColumn('workflow_step_id', 'text', (col) =>
|
|
63
|
-
col
|
|
64
|
-
.primaryKey()
|
|
65
|
-
.defaultTo(sql`${sql.raw("'" + crypto.randomUUID() + "'")}`)
|
|
66
|
-
)
|
|
67
|
-
.addColumn('workflow_run_id', 'text', (col) =>
|
|
68
|
-
col
|
|
69
|
-
.notNull()
|
|
70
|
-
.references('workflow_runs.workflow_run_id')
|
|
71
|
-
.onDelete('cascade')
|
|
72
|
-
)
|
|
73
|
-
.addColumn('step_name', 'text', (col) => col.notNull())
|
|
74
|
-
.addColumn('rpc_name', 'text')
|
|
75
|
-
.addColumn('data', 'text')
|
|
76
|
-
.addColumn('status', 'text', (col) => col.notNull().defaultTo('pending'))
|
|
77
|
-
.addColumn('result', 'text')
|
|
78
|
-
.addColumn('error', 'text')
|
|
79
|
-
.addColumn('child_run_id', 'text')
|
|
80
|
-
.addColumn('branch_taken', 'text')
|
|
81
|
-
.addColumn('retries', 'integer')
|
|
82
|
-
.addColumn('retry_delay', 'text')
|
|
83
|
-
.addColumn('from_step_name', 'text')
|
|
84
|
-
.addColumn('created_at', 'timestamp', (col) =>
|
|
85
|
-
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
86
|
-
)
|
|
87
|
-
.addColumn('updated_at', 'timestamp', (col) =>
|
|
88
|
-
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
89
|
-
)
|
|
90
|
-
.addUniqueConstraint('workflow_step_run_name_unique', [
|
|
91
|
-
'workflow_run_id',
|
|
92
|
-
'step_name',
|
|
93
|
-
])
|
|
94
|
-
.execute()
|
|
95
|
-
|
|
96
|
-
// Additive: backfill the column on tables created before from_step_name.
|
|
97
|
-
await this.db.schema
|
|
98
|
-
.alterTable('workflow_step')
|
|
99
|
-
.addColumn('from_step_name', 'text')
|
|
100
|
-
.execute()
|
|
101
|
-
.catch(() => {
|
|
102
|
-
// Column already exists (fresh tables include it above) — nothing to do.
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
await this.db.schema
|
|
106
|
-
.createTable('workflow_step_history')
|
|
107
|
-
.ifNotExists()
|
|
108
|
-
.addColumn('history_id', 'text', (col) =>
|
|
109
|
-
col
|
|
110
|
-
.primaryKey()
|
|
111
|
-
.defaultTo(sql`${sql.raw("'" + crypto.randomUUID() + "'")}`)
|
|
112
|
-
)
|
|
113
|
-
.addColumn('workflow_step_id', 'text', (col) =>
|
|
114
|
-
col
|
|
115
|
-
.notNull()
|
|
116
|
-
.references('workflow_step.workflow_step_id')
|
|
117
|
-
.onDelete('cascade')
|
|
118
|
-
)
|
|
119
|
-
.addColumn('status', 'text', (col) => col.notNull())
|
|
120
|
-
.addColumn('result', 'text')
|
|
121
|
-
.addColumn('error', 'text')
|
|
122
|
-
.addColumn('created_at', 'timestamp', (col) =>
|
|
123
|
-
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
124
|
-
)
|
|
125
|
-
.addColumn('running_at', 'timestamp')
|
|
126
|
-
.addColumn('scheduled_at', 'timestamp')
|
|
127
|
-
.addColumn('succeeded_at', 'timestamp')
|
|
128
|
-
.addColumn('failed_at', 'timestamp')
|
|
129
|
-
.execute()
|
|
130
|
-
|
|
131
|
-
await this.db.schema
|
|
132
|
-
.createTable('workflow_versions')
|
|
133
|
-
.ifNotExists()
|
|
134
|
-
.addColumn('workflow_name', 'text', (col) => col.notNull())
|
|
135
|
-
.addColumn('graph_hash', 'text', (col) => col.notNull())
|
|
136
|
-
.addColumn('graph', 'text', (col) => col.notNull())
|
|
137
|
-
.addColumn('source', 'text', (col) => col.notNull())
|
|
138
|
-
.addColumn('status', 'text', (col) => col.notNull().defaultTo('active'))
|
|
139
|
-
.addColumn('created_at', 'timestamp', (col) =>
|
|
140
|
-
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
141
|
-
)
|
|
142
|
-
.addPrimaryKeyConstraint('workflow_versions_pk', [
|
|
143
|
-
'workflow_name',
|
|
144
|
-
'graph_hash',
|
|
145
|
-
])
|
|
146
|
-
.execute()
|
|
147
|
-
|
|
33
|
+
if (this.initialized) return
|
|
34
|
+
await ensurePikkuSchema(this.db, workflowSchema)
|
|
148
35
|
this.initialized = true
|
|
149
36
|
}
|
|
150
37
|
|
|
@@ -417,7 +304,11 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
417
304
|
if (latestHistory) {
|
|
418
305
|
await this.db
|
|
419
306
|
.updateTable('workflowStepHistory')
|
|
420
|
-
.set({
|
|
307
|
+
.set({
|
|
308
|
+
status: 'succeeded',
|
|
309
|
+
result: resultJson,
|
|
310
|
+
succeededAt: new Date(),
|
|
311
|
+
})
|
|
421
312
|
.where('historyId', '=', latestHistory.historyId)
|
|
422
313
|
.execute()
|
|
423
314
|
}
|
|
@@ -586,7 +477,9 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
|
|
|
586
477
|
return nodeIds.filter((id) => !existingStepNames.has(id))
|
|
587
478
|
}
|
|
588
479
|
|
|
589
|
-
async getStepInstances(
|
|
480
|
+
async getStepInstances(
|
|
481
|
+
runId: string
|
|
482
|
+
): Promise<
|
|
590
483
|
Array<{ stepName: string; status: StepStatus; fromStepName?: string }>
|
|
591
484
|
> {
|
|
592
485
|
const rows = await this.db
|