@pikku/kysely 0.13.3 → 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 (70) hide show
  1. package/CHANGELOG.md +27 -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.js +9 -93
  20. package/dist/src/schema/ai.schema.d.ts +12 -0
  21. package/dist/src/schema/ai.schema.js +89 -0
  22. package/dist/src/schema/channel.schema.d.ts +3 -0
  23. package/dist/src/schema/channel.schema.js +24 -0
  24. package/dist/src/schema/credential.schema.d.ts +11 -0
  25. package/dist/src/schema/credential.schema.js +33 -0
  26. package/dist/src/schema/deployment.schema.d.ts +3 -0
  27. package/dist/src/schema/deployment.schema.js +32 -0
  28. package/dist/src/schema/index.d.ts +82 -0
  29. package/dist/src/schema/index.js +187 -0
  30. package/dist/src/schema/pikku-schema.types.d.ts +75 -0
  31. package/dist/src/schema/pikku-schema.types.js +21 -0
  32. package/dist/src/schema/scope.schema.d.ts +18 -0
  33. package/dist/src/schema/scope.schema.js +61 -0
  34. package/dist/src/schema/secret.schema.d.ts +11 -0
  35. package/dist/src/schema/secret.schema.js +29 -0
  36. package/dist/src/schema/session.schema.d.ts +3 -0
  37. package/dist/src/schema/session.schema.js +13 -0
  38. package/dist/src/schema/webhook.schema.d.ts +3 -0
  39. package/dist/src/schema/webhook.schema.js +38 -0
  40. package/dist/src/schema/workflow.schema.d.ts +15 -0
  41. package/dist/src/schema/workflow.schema.js +86 -0
  42. package/dist/tsconfig.tsbuildinfo +1 -1
  43. package/package.json +1 -1
  44. package/src/index.ts +18 -0
  45. package/src/kysely-ai-run-state-service.ts +7 -28
  46. package/src/kysely-ai-storage-service.ts +4 -152
  47. package/src/kysely-channel-store.ts +4 -34
  48. package/src/kysely-credential-service.ts +3 -36
  49. package/src/kysely-deployment-service.ts +4 -63
  50. package/src/kysely-scope-service.ts +4 -81
  51. package/src/kysely-secret-service.ts +6 -33
  52. package/src/kysely-session-store.ts +4 -18
  53. package/src/kysely-tables.test.ts +110 -0
  54. package/src/kysely-tables.ts +2 -0
  55. package/src/kysely-webhook-service.ts +4 -67
  56. package/src/kysely-workflow-mirror.test.ts +7 -7
  57. package/src/kysely-workflow-mirror.ts +10 -109
  58. package/src/kysely-workflow-service.ts +12 -123
  59. package/src/schema/ai.schema.ts +140 -0
  60. package/src/schema/channel.schema.ts +37 -0
  61. package/src/schema/credential.schema.ts +46 -0
  62. package/src/schema/deployment.schema.ts +47 -0
  63. package/src/schema/index.ts +249 -0
  64. package/src/schema/pikku-schema.types.ts +94 -0
  65. package/src/schema/schema.test.ts +273 -0
  66. package/src/schema/scope.schema.ts +95 -0
  67. package/src/schema/secret.schema.ts +40 -0
  68. package/src/schema/session.schema.ts +20 -0
  69. package/src/schema/webhook.schema.ts +57 -0
  70. 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
 
@@ -11,10 +11,11 @@ import {
11
11
  type WorkflowVersionStatus,
12
12
  } from '@pikku/core/workflow'
13
13
  import type { Kysely } from 'kysely'
14
- import { sql } from 'kysely'
15
14
  import type { KyselyPikkuDB } from './kysely-tables.js'
16
15
  import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js'
17
16
  import { parseJson } from './kysely-json.js'
17
+ import { ensurePikkuSchema } from './schema/index.js'
18
+ import { workflowSchema } from './schema/workflow.schema.js'
18
19
 
19
20
  export class KyselyWorkflowService extends PikkuWorkflowService {
20
21
  private initialized = false
@@ -29,126 +30,8 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
29
30
  }
30
31
 
31
32
  public async init(): Promise<void> {
32
- if (this.initialized) {
33
- return
34
- }
35
-
36
- await this.db.schema
37
- .createTable('workflow_runs')
38
- .ifNotExists()
39
- .addColumn('workflow_run_id', 'text', (col) =>
40
- col
41
- .primaryKey()
42
- .defaultTo(sql`${sql.raw("'" + crypto.randomUUID() + "'")}`)
43
- )
44
- .addColumn('workflow', 'text', (col) => col.notNull())
45
- .addColumn('status', 'text', (col) => col.notNull())
46
- .addColumn('input', 'text', (col) => col.notNull())
47
- .addColumn('output', 'text')
48
- .addColumn('error', 'text')
49
- .addColumn('state', 'text', (col) => col.defaultTo('{}'))
50
- .addColumn('inline', 'boolean', (col) => col.defaultTo(false))
51
- .addColumn('graph_hash', 'text')
52
- .addColumn('deterministic', 'boolean', (col) => col.defaultTo(false))
53
- .addColumn('planned_steps', 'text')
54
- .addColumn('wire', 'text')
55
- .addColumn('created_at', 'timestamp', (col) =>
56
- col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
57
- )
58
- .addColumn('updated_at', 'timestamp', (col) =>
59
- col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
60
- )
61
- .execute()
62
-
63
- await this.db.schema
64
- .createTable('workflow_step')
65
- .ifNotExists()
66
- .addColumn('workflow_step_id', 'text', (col) =>
67
- col
68
- .primaryKey()
69
- .defaultTo(sql`${sql.raw("'" + crypto.randomUUID() + "'")}`)
70
- )
71
- .addColumn('workflow_run_id', 'text', (col) =>
72
- col
73
- .notNull()
74
- .references('workflow_runs.workflow_run_id')
75
- .onDelete('cascade')
76
- )
77
- .addColumn('step_name', 'text', (col) => col.notNull())
78
- .addColumn('rpc_name', 'text')
79
- .addColumn('data', 'text')
80
- .addColumn('status', 'text', (col) => col.notNull().defaultTo('pending'))
81
- .addColumn('result', 'text')
82
- .addColumn('error', 'text')
83
- .addColumn('child_run_id', 'text')
84
- .addColumn('branch_taken', 'text')
85
- .addColumn('retries', 'integer')
86
- .addColumn('retry_delay', 'text')
87
- .addColumn('from_step_name', 'text')
88
- .addColumn('created_at', 'timestamp', (col) =>
89
- col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
90
- )
91
- .addColumn('updated_at', 'timestamp', (col) =>
92
- col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
93
- )
94
- .addUniqueConstraint('workflow_step_run_name_unique', [
95
- 'workflow_run_id',
96
- 'step_name',
97
- ])
98
- .execute()
99
-
100
- // Additive: backfill the column on tables created before from_step_name.
101
- await this.db.schema
102
- .alterTable('workflow_step')
103
- .addColumn('from_step_name', 'text')
104
- .execute()
105
- .catch(() => {
106
- // Column already exists (fresh tables include it above) — nothing to do.
107
- })
108
-
109
- await this.db.schema
110
- .createTable('workflow_step_history')
111
- .ifNotExists()
112
- .addColumn('history_id', 'text', (col) =>
113
- col
114
- .primaryKey()
115
- .defaultTo(sql`${sql.raw("'" + crypto.randomUUID() + "'")}`)
116
- )
117
- .addColumn('workflow_step_id', 'text', (col) =>
118
- col
119
- .notNull()
120
- .references('workflow_step.workflow_step_id')
121
- .onDelete('cascade')
122
- )
123
- .addColumn('status', 'text', (col) => col.notNull())
124
- .addColumn('result', 'text')
125
- .addColumn('error', 'text')
126
- .addColumn('created_at', 'timestamp', (col) =>
127
- col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
128
- )
129
- .addColumn('running_at', 'timestamp')
130
- .addColumn('scheduled_at', 'timestamp')
131
- .addColumn('succeeded_at', 'timestamp')
132
- .addColumn('failed_at', 'timestamp')
133
- .execute()
134
-
135
- await this.db.schema
136
- .createTable('workflow_versions')
137
- .ifNotExists()
138
- .addColumn('workflow_name', 'text', (col) => col.notNull())
139
- .addColumn('graph_hash', 'text', (col) => col.notNull())
140
- .addColumn('graph', 'text', (col) => col.notNull())
141
- .addColumn('source', 'text', (col) => col.notNull())
142
- .addColumn('status', 'text', (col) => col.notNull().defaultTo('active'))
143
- .addColumn('created_at', 'timestamp', (col) =>
144
- col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
145
- )
146
- .addPrimaryKeyConstraint('workflow_versions_pk', [
147
- 'workflow_name',
148
- 'graph_hash',
149
- ])
150
- .execute()
151
-
33
+ if (this.initialized) return
34
+ await ensurePikkuSchema(this.db, workflowSchema)
152
35
  this.initialized = true
153
36
  }
154
37
 
@@ -421,7 +304,11 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
421
304
  if (latestHistory) {
422
305
  await this.db
423
306
  .updateTable('workflowStepHistory')
424
- .set({ status: 'succeeded', result: resultJson, succeededAt: new Date() })
307
+ .set({
308
+ status: 'succeeded',
309
+ result: resultJson,
310
+ succeededAt: new Date(),
311
+ })
425
312
  .where('historyId', '=', latestHistory.historyId)
426
313
  .execute()
427
314
  }
@@ -590,7 +477,9 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
590
477
  return nodeIds.filter((id) => !existingStepNames.has(id))
591
478
  }
592
479
 
593
- async getStepInstances(runId: string): Promise<
480
+ async getStepInstances(
481
+ runId: string
482
+ ): Promise<
594
483
  Array<{ stepName: string; status: StepStatus; fromStepName?: string }>
595
484
  > {
596
485
  const rows = await this.db
@@ -0,0 +1,140 @@
1
+ import { sql } from 'kysely'
2
+ import type { PikkuSchema } from './pikku-schema.types.js'
3
+
4
+ /**
5
+ * Threads, messages, tool calls, working memory and agent runs.
6
+ *
7
+ * `aiRun` carries `pendingApprovals` even though the two services that used to
8
+ * create this table disagreed about it: `KyselyAIStorageService` omitted the
9
+ * column while `KyselyAIRunStateService` declared it and read it back through
10
+ * casts. Whichever service happened to run first decided the shape, and on a
11
+ * database where the storage service won, resolving an approval failed. One
12
+ * declaration ends that — the column is here because live code reads it.
13
+ */
14
+ export const aiSchema: PikkuSchema = {
15
+ name: 'ai',
16
+ statements: [
17
+ (db) =>
18
+ db.schema
19
+ .createTable('aiThreads')
20
+ .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
21
+ .addColumn('resourceId', 'varchar(255)', (col) => col.notNull())
22
+ .addColumn('title', 'text')
23
+ .addColumn('metadata', 'text')
24
+ .addColumn('createdAt', 'timestamp', (col) =>
25
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
26
+ )
27
+ .addColumn('updatedAt', 'timestamp', (col) =>
28
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
29
+ ),
30
+
31
+ (db) =>
32
+ db.schema
33
+ .createIndex('idx_ai_threads_resource')
34
+ .on('aiThreads')
35
+ .column('resourceId'),
36
+
37
+ (db) =>
38
+ db.schema
39
+ .createTable('aiMessage')
40
+ .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
41
+ .addColumn('threadId', 'varchar(36)', (col) =>
42
+ col.notNull().references('aiThreads.id').onDelete('cascade')
43
+ )
44
+ .addColumn('role', 'varchar(50)', (col) => col.notNull())
45
+ .addColumn('content', 'text')
46
+ .addColumn('createdAt', 'timestamp', (col) =>
47
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
48
+ ),
49
+
50
+ (db) =>
51
+ db.schema
52
+ .createIndex('idx_ai_message_thread')
53
+ .on('aiMessage')
54
+ .columns(['threadId', 'createdAt']),
55
+
56
+ (db) =>
57
+ db.schema
58
+ .createTable('aiToolCall')
59
+ .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
60
+ .addColumn('threadId', 'varchar(36)', (col) =>
61
+ col.notNull().references('aiThreads.id').onDelete('cascade')
62
+ )
63
+ .addColumn('messageId', 'varchar(36)', (col) =>
64
+ col.notNull().references('aiMessage.id').onDelete('cascade')
65
+ )
66
+ .addColumn('runId', 'varchar(36)')
67
+ .addColumn('toolName', 'varchar(255)', (col) => col.notNull())
68
+ .addColumn('args', 'text', (col) => col.notNull())
69
+ .addColumn('result', 'text')
70
+ .addColumn('approvalStatus', 'varchar(50)')
71
+ .addColumn('approvalType', 'varchar(50)')
72
+ .addColumn('agentRunId', 'varchar(36)')
73
+ .addColumn('displayToolName', 'varchar(255)')
74
+ .addColumn('displayArgs', 'text')
75
+ .addColumn('createdAt', 'timestamp', (col) =>
76
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
77
+ ),
78
+
79
+ (db) =>
80
+ db.schema
81
+ .createIndex('idx_ai_tool_call_thread')
82
+ .on('aiToolCall')
83
+ .column('threadId'),
84
+
85
+ (db) =>
86
+ db.schema
87
+ .createIndex('idx_ai_tool_call_message')
88
+ .on('aiToolCall')
89
+ .column('messageId'),
90
+
91
+ (db) =>
92
+ db.schema
93
+ .createTable('aiWorkingMemory')
94
+ .addColumn('id', 'varchar(255)', (col) => col.notNull())
95
+ .addColumn('scope', 'varchar(50)', (col) => col.notNull())
96
+ .addColumn('data', 'text', (col) => col.notNull())
97
+ .addColumn('updatedAt', 'timestamp', (col) =>
98
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
99
+ )
100
+ .addPrimaryKeyConstraint('ai_working_memory_pk', ['id', 'scope']),
101
+
102
+ (db) =>
103
+ db.schema
104
+ .createTable('aiRun')
105
+ .addColumn('runId', 'varchar(36)', (col) => col.primaryKey())
106
+ .addColumn('agentName', 'varchar(255)', (col) => col.notNull())
107
+ .addColumn('threadId', 'varchar(36)', (col) =>
108
+ col.notNull().references('aiThreads.id').onDelete('cascade')
109
+ )
110
+ .addColumn('resourceId', 'varchar(255)', (col) => col.notNull())
111
+ .addColumn('status', 'varchar(50)', (col) =>
112
+ col.notNull().defaultTo('running')
113
+ )
114
+ .addColumn('errorMessage', 'text')
115
+ .addColumn('suspendReason', 'text')
116
+ .addColumn('missingRpcs', 'text')
117
+ .addColumn('pendingApprovals', 'text')
118
+ .addColumn('usageInputTokens', 'integer', (col) =>
119
+ col.notNull().defaultTo(0)
120
+ )
121
+ .addColumn('usageOutputTokens', 'integer', (col) =>
122
+ col.notNull().defaultTo(0)
123
+ )
124
+ .addColumn('usageModel', 'varchar(255)', (col) =>
125
+ col.notNull().defaultTo('')
126
+ )
127
+ .addColumn('createdAt', 'timestamp', (col) =>
128
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
129
+ )
130
+ .addColumn('updatedAt', 'timestamp', (col) =>
131
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
132
+ ),
133
+
134
+ (db) =>
135
+ db.schema
136
+ .createIndex('idx_ai_run_thread')
137
+ .on('aiRun')
138
+ .columns(['threadId', 'createdAt']),
139
+ ],
140
+ }