@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.
Files changed (71) hide show
  1. package/CHANGELOG.md +56 -0
  2. package/dist/src/index.d.ts +1 -0
  3. package/dist/src/index.js +1 -0
  4. package/dist/src/kysely-ai-run-state-service.js +3 -18
  5. package/dist/src/kysely-ai-storage-service.d.ts +0 -1
  6. package/dist/src/kysely-ai-storage-service.js +4 -101
  7. package/dist/src/kysely-channel-store.js +4 -24
  8. package/dist/src/kysely-credential-service.js +3 -24
  9. package/dist/src/kysely-deployment-service.d.ts +0 -1
  10. package/dist/src/kysely-deployment-service.js +4 -48
  11. package/dist/src/kysely-scope-service.js +4 -54
  12. package/dist/src/kysely-secret-service.js +3 -21
  13. package/dist/src/kysely-session-store.js +4 -11
  14. package/dist/src/kysely-tables.d.ts +2 -0
  15. package/dist/src/kysely-webhook-service.d.ts +0 -1
  16. package/dist/src/kysely-webhook-service.js +4 -51
  17. package/dist/src/kysely-workflow-mirror.d.ts +7 -3
  18. package/dist/src/kysely-workflow-mirror.js +10 -87
  19. package/dist/src/kysely-workflow-service.d.ts +2 -2
  20. package/dist/src/kysely-workflow-service.js +11 -95
  21. package/dist/src/schema/ai.schema.d.ts +12 -0
  22. package/dist/src/schema/ai.schema.js +89 -0
  23. package/dist/src/schema/channel.schema.d.ts +3 -0
  24. package/dist/src/schema/channel.schema.js +24 -0
  25. package/dist/src/schema/credential.schema.d.ts +11 -0
  26. package/dist/src/schema/credential.schema.js +33 -0
  27. package/dist/src/schema/deployment.schema.d.ts +3 -0
  28. package/dist/src/schema/deployment.schema.js +32 -0
  29. package/dist/src/schema/index.d.ts +82 -0
  30. package/dist/src/schema/index.js +187 -0
  31. package/dist/src/schema/pikku-schema.types.d.ts +75 -0
  32. package/dist/src/schema/pikku-schema.types.js +21 -0
  33. package/dist/src/schema/scope.schema.d.ts +18 -0
  34. package/dist/src/schema/scope.schema.js +61 -0
  35. package/dist/src/schema/secret.schema.d.ts +11 -0
  36. package/dist/src/schema/secret.schema.js +29 -0
  37. package/dist/src/schema/session.schema.d.ts +3 -0
  38. package/dist/src/schema/session.schema.js +13 -0
  39. package/dist/src/schema/webhook.schema.d.ts +3 -0
  40. package/dist/src/schema/webhook.schema.js +38 -0
  41. package/dist/src/schema/workflow.schema.d.ts +15 -0
  42. package/dist/src/schema/workflow.schema.js +86 -0
  43. package/dist/tsconfig.tsbuildinfo +1 -1
  44. package/package.json +2 -2
  45. package/src/index.ts +18 -0
  46. package/src/kysely-ai-run-state-service.ts +7 -28
  47. package/src/kysely-ai-storage-service.ts +4 -152
  48. package/src/kysely-channel-store.ts +4 -34
  49. package/src/kysely-credential-service.ts +3 -36
  50. package/src/kysely-deployment-service.ts +4 -63
  51. package/src/kysely-scope-service.ts +4 -81
  52. package/src/kysely-secret-service.ts +6 -33
  53. package/src/kysely-session-store.ts +4 -18
  54. package/src/kysely-tables.test.ts +110 -0
  55. package/src/kysely-tables.ts +2 -0
  56. package/src/kysely-webhook-service.ts +4 -67
  57. package/src/kysely-workflow-mirror.test.ts +7 -7
  58. package/src/kysely-workflow-mirror.ts +10 -109
  59. package/src/kysely-workflow-service.ts +18 -125
  60. package/src/schema/ai.schema.ts +140 -0
  61. package/src/schema/channel.schema.ts +37 -0
  62. package/src/schema/credential.schema.ts +46 -0
  63. package/src/schema/deployment.schema.ts +47 -0
  64. package/src/schema/index.ts +249 -0
  65. package/src/schema/pikku-schema.types.ts +94 -0
  66. package/src/schema/schema.test.ts +273 -0
  67. package/src/schema/scope.schema.ts +95 -0
  68. package/src/schema/secret.schema.ts +40 -0
  69. package/src/schema/session.schema.ts +20 -0
  70. package/src/schema/webhook.schema.ts +57 -0
  71. 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
+ })
@@ -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
- return
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 schema (ifNotExists)', async () => {
262
- // Same DB, schema already created by mirror.init() in beforeEach.
263
- // If KyselyWorkflowService.init() ran on the same DB, both should
264
- // succeed because both use ifNotExists. We can't import it without
265
- // the singleton-services bootstrap, so simulate by re-calling our
266
- // own init — proves the SQL is idempotent.
267
- await assert.doesNotReject(mirror.init())
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 they don't exist. Safe to call from
33
- * either the mirror or `KyselyWorkflowService.init()` — schema is the
34
- * same and uses `ifNotExists`.
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(protected db: Kysely<KyselyPikkuDB>) {
23
- super()
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
- return
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({ status: 'succeeded', result: resultJson, succeededAt: new Date() })
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(runId: string): Promise<
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