@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,273 @@
1
+ import { test, describe } from 'node:test'
2
+ import assert from 'node:assert/strict'
3
+ import {
4
+ Kysely,
5
+ PostgresAdapter,
6
+ PostgresIntrospector,
7
+ PostgresQueryCompiler,
8
+ SqliteAdapter,
9
+ SqliteIntrospector,
10
+ SqliteQueryCompiler,
11
+ SqliteDialect,
12
+ DummyDriver,
13
+ } from 'kysely'
14
+ import Database from 'better-sqlite3'
15
+ import {
16
+ pikkuSchemas,
17
+ compilePikkuSchemas,
18
+ applyPikkuSchemas,
19
+ ensurePikkuSchema,
20
+ unsatisfiedRequirements,
21
+ type PikkuSchema,
22
+ } from './index.js'
23
+ import { workflowSchema } from './workflow.schema.js'
24
+
25
+ const dialect = (kind: 'postgres' | 'sqlite') =>
26
+ kind === 'postgres'
27
+ ? {
28
+ createAdapter: () => new PostgresAdapter(),
29
+ createDriver: () => new DummyDriver(),
30
+ createIntrospector: (db: Kysely<any>) => new PostgresIntrospector(db),
31
+ createQueryCompiler: () => new PostgresQueryCompiler(),
32
+ }
33
+ : {
34
+ createAdapter: () => new SqliteAdapter(),
35
+ createDriver: () => new DummyDriver(),
36
+ createIntrospector: (db: Kysely<any>) => new SqliteIntrospector(db),
37
+ createQueryCompiler: () => new SqliteQueryCompiler(),
38
+ }
39
+
40
+ const compileFor = (kind: 'postgres' | 'sqlite') =>
41
+ compilePikkuSchemas(new Kysely<any>({ dialect: dialect(kind) }))
42
+
43
+ describe('pikku runtime schema', () => {
44
+ test('declares logical names and compiles them to physical ones', () => {
45
+ const sql = compileFor('postgres')
46
+
47
+ // The declaration says `aiThreads.resourceId`, matching how every query in
48
+ // the package addresses it; the plugin is what makes the table `ai_threads`.
49
+ assert.match(sql, /create table "ai_threads"/)
50
+ assert.match(sql, /"resource_id" varchar\(255\) not null/)
51
+ assert.doesNotMatch(
52
+ sql,
53
+ /"aiThreads"/,
54
+ 'a camelCase table name means the plugin was not applied'
55
+ )
56
+ })
57
+
58
+ test('rewrites foreign key targets too', () => {
59
+ assert.match(
60
+ compileFor('postgres'),
61
+ /references "ai_threads" \("id"\)/,
62
+ 'an unrewritten reference target points at a table that does not exist'
63
+ )
64
+ })
65
+
66
+ test('compiles for every dialect from the one declaration', () => {
67
+ for (const kind of ['postgres', 'sqlite'] as const) {
68
+ const sql = compileFor(kind)
69
+ assert.match(sql, /create table "workflow_runs"/, `${kind} workflow_runs`)
70
+ assert.match(sql, /create table "pikku_scopes"/, `${kind} pikku_scopes`)
71
+ }
72
+ })
73
+
74
+ test('carries the ai_run column the two old declarations disagreed on', () => {
75
+ assert.match(compileFor('postgres'), /"pending_approvals" text/)
76
+ })
77
+
78
+ test('creates tables outright rather than if-not-exists', () => {
79
+ // A migration runs once. `if not exists` would let a table that already
80
+ // exists in a different shape pass silently, which is the drift the
81
+ // migration is supposed to surface.
82
+ assert.doesNotMatch(compileFor('postgres'), /create table if not exists/)
83
+ })
84
+
85
+ test('drops the frozen uuid default from workflow primary keys', () => {
86
+ // It was `sql.raw("'" + crypto.randomUUID() + "'")`, evaluated once when the
87
+ // statement was built, so every row shared one default.
88
+ assert.match(
89
+ compileFor('postgres'),
90
+ /"workflow_run_id" text primary key,/,
91
+ 'the primary key should carry no default at all'
92
+ )
93
+ assert.match(compileFor('postgres'), /"workflow_step_id" text primary key,/)
94
+ assert.match(compileFor('postgres'), /"history_id" text primary key,/)
95
+ })
96
+
97
+ test('every schema is named, for the migration it will be written into', () => {
98
+ const names = pikkuSchemas.map((s) => s.name)
99
+ assert.equal(new Set(names).size, names.length, 'names must be unique')
100
+ assert.ok(names.every((n) => /^[a-z][a-z-]*$/.test(n)))
101
+ })
102
+ })
103
+
104
+ describe('pikku runtime schema — prerequisites', () => {
105
+ const memoryDb = () =>
106
+ new Kysely<any>({
107
+ dialect: new SqliteDialect({ database: new Database(':memory:') }),
108
+ })
109
+
110
+ test('names the missing table and who owns it, before creating anything', async () => {
111
+ const db = memoryDb()
112
+ try {
113
+ await assert.rejects(
114
+ applyPikkuSchemas(db),
115
+ /The 'scope' schema requires 'user\.id', which nothing has created\. Better Auth owns it/
116
+ )
117
+
118
+ const created = await db.introspection.getTables()
119
+ assert.deepEqual(
120
+ created.map((t) => t.name),
121
+ [],
122
+ 'a failed prerequisite must not leave a half-applied database'
123
+ )
124
+ } finally {
125
+ await db.destroy()
126
+ }
127
+ })
128
+
129
+ test('applies in full once the prerequisite is there', async () => {
130
+ const db = memoryDb()
131
+ try {
132
+ await db.schema
133
+ .createTable('user')
134
+ .addColumn('id', 'text', (col) => col.primaryKey())
135
+ .execute()
136
+
137
+ assert.deepEqual(await unsatisfiedRequirements(db), [])
138
+ await applyPikkuSchemas(db)
139
+
140
+ const created = new Set(
141
+ (await db.introspection.getTables()).map((t) => t.name)
142
+ )
143
+ assert.ok(created.has('pikku_user_role'))
144
+ assert.ok(created.has('workflow_runs'))
145
+ } finally {
146
+ await db.destroy()
147
+ }
148
+ })
149
+
150
+ test('takes the grant column type from the user id it references', () => {
151
+ // The hand-written DDL said `text`. Better Auth generates a `uuid` primary
152
+ // key on postgres, and postgres rejects a text column referencing it —
153
+ // which is why projects ended up writing these tables by hand.
154
+ const sql = compilePikkuSchemas(
155
+ new Kysely<any>({ dialect: dialect('postgres') }),
156
+ pikkuSchemas,
157
+ { 'user.id': 'uuid' }
158
+ )
159
+ assert.match(
160
+ sql,
161
+ /create table "pikku_user_role" \("user_id" uuid not null references "user" \("id"\)/
162
+ )
163
+ assert.match(
164
+ sql,
165
+ /create table "pikku_user_scope" \("user_id" uuid not null references "user" \("id"\)/
166
+ )
167
+ })
168
+
169
+ test('reports the gap as data, so a caller can describe it instead of dying', async () => {
170
+ const db = memoryDb()
171
+ try {
172
+ const unmet = await unsatisfiedRequirements(db)
173
+ assert.deepEqual(
174
+ unmet.map(({ schema, requirement }) => ({
175
+ schema: schema.name,
176
+ ...requirement,
177
+ })),
178
+ [{ schema: 'scope', table: 'user', column: 'id', owner: 'Better Auth' }]
179
+ )
180
+
181
+ // The rest of the declaration does not depend on auth, so it still applies.
182
+ const satisfiable = pikkuSchemas.filter((s) => s.name !== 'scope')
183
+ await applyPikkuSchemas(db, satisfiable)
184
+ const created = new Set(
185
+ (await db.introspection.getTables()).map((t) => t.name)
186
+ )
187
+ assert.ok(created.has('workflow_runs'))
188
+ assert.equal(created.has('pikku_user_role'), false)
189
+ } finally {
190
+ await db.destroy()
191
+ }
192
+ })
193
+
194
+ test('a statement failing part way takes the ones before it with it', async () => {
195
+ const db = memoryDb()
196
+ try {
197
+ await db.schema.createTable('taken').addColumn('id', 'text').execute()
198
+
199
+ // The prerequisite check cannot see this coming: nothing is missing, the
200
+ // second statement simply collides with a table the database already has.
201
+ const collides: PikkuSchema = {
202
+ name: 'collides',
203
+ statements: [
204
+ (db) => db.schema.createTable('first').addColumn('id', 'text'),
205
+ (db) => db.schema.createTable('taken').addColumn('id', 'text'),
206
+ ],
207
+ }
208
+
209
+ await assert.rejects(applyPikkuSchemas(db, [collides]))
210
+
211
+ const created = new Set(
212
+ (await db.introspection.getTables()).map((t) => t.name)
213
+ )
214
+ assert.equal(
215
+ created.has('first'),
216
+ false,
217
+ 'the statement that succeeded must roll back with the one that did not'
218
+ )
219
+ } finally {
220
+ await db.destroy()
221
+ }
222
+ })
223
+ })
224
+
225
+ describe('ensurePikkuSchema — what a service does at boot', () => {
226
+ const memoryDb = () =>
227
+ new Kysely<any>({
228
+ dialect: new SqliteDialect({ database: new Database(':memory:') }),
229
+ })
230
+
231
+ test('creates the tables the first time and finds them the second', async () => {
232
+ const db = memoryDb()
233
+ try {
234
+ assert.equal(await ensurePikkuSchema(db, workflowSchema), 'created')
235
+ assert.equal(await ensurePikkuSchema(db, workflowSchema), 'present')
236
+ } finally {
237
+ await db.destroy()
238
+ }
239
+ })
240
+
241
+ test('two services declaring the same schema do not fight over it', async () => {
242
+ // The workflow service and the workflow mirror both own these tables, and
243
+ // both call init(). Whoever runs second must find them and stop, rather
244
+ // than issuing DDL the database will reject.
245
+ const db = memoryDb()
246
+ try {
247
+ assert.equal(await ensurePikkuSchema(db, workflowSchema), 'created')
248
+ assert.equal(await ensurePikkuSchema(db, workflowSchema), 'present')
249
+ } finally {
250
+ await db.destroy()
251
+ }
252
+ })
253
+
254
+ test('refuses a half-applied schema rather than filling in the rest', async () => {
255
+ // Something else already owns part of it — a migration, an older release,
256
+ // a hand-run script. Creating the remainder at boot leaves two authorities
257
+ // over one set of tables, which is the condition all of this exists to end.
258
+ const db = memoryDb()
259
+ try {
260
+ await db.schema
261
+ .createTable('workflow_runs')
262
+ .addColumn('workflow_run_id', 'text', (col) => col.primaryKey())
263
+ .execute()
264
+
265
+ await assert.rejects(
266
+ ensurePikkuSchema(db, workflowSchema),
267
+ /The 'workflow' schema is half applied/
268
+ )
269
+ } finally {
270
+ await db.destroy()
271
+ }
272
+ })
273
+ })
@@ -0,0 +1,95 @@
1
+ import { sql } from 'kysely'
2
+ import { requiredType, type PikkuSchema } from './pikku-schema.types.js'
3
+
4
+ /**
5
+ * Scopes, roles and the grants that bind them to users.
6
+ *
7
+ * `pikkuUserRole` and `pikkuUserScope` reference `user.id`, which Better Auth
8
+ * owns. Auth is a prerequisite rather than an optional companion: a grant is
9
+ * made to a user, so without the table the user lives in there is nothing to
10
+ * grant to, and the cascade that revokes grants when a user is deleted has
11
+ * nothing to hang off.
12
+ *
13
+ * `userId` takes its type from `user.id` rather than declaring one, because
14
+ * that type is Better Auth's to decide: `text` by default, `uuid` under
15
+ * `generateId: 'uuid'`, an identity `integer` under `'serial'`. The old
16
+ * hand-written DDL said `text`, and postgres rejects a `text` column
17
+ * referencing either of the others — so the whole statement failed and projects
18
+ * wrote these tables by hand instead.
19
+ */
20
+ export const scopeSchema: PikkuSchema = {
21
+ name: 'scope',
22
+ requires: [{ table: 'user', column: 'id', owner: 'Better Auth' }],
23
+ statements: [
24
+ (db) =>
25
+ db.schema
26
+ .createTable('pikkuScopes')
27
+ .addColumn('name', 'text', (col) => col.primaryKey())
28
+ .addColumn('description', 'text')
29
+ .addColumn('declared', 'boolean', (col) =>
30
+ col.defaultTo(true).notNull()
31
+ ),
32
+
33
+ (db) =>
34
+ db.schema
35
+ .createTable('pikkuRoles')
36
+ .addColumn('name', 'text', (col) => col.primaryKey())
37
+ .addColumn('description', 'text')
38
+ .addColumn('createdAt', 'timestamp', (col) =>
39
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
40
+ ),
41
+
42
+ (db) =>
43
+ db.schema
44
+ .createTable('pikkuRoleScopes')
45
+ .addColumn('role', 'text', (col) =>
46
+ col.notNull().references('pikkuRoles.name').onDelete('cascade')
47
+ )
48
+ .addColumn('scope', 'text', (col) =>
49
+ col.notNull().references('pikkuScopes.name').onDelete('cascade')
50
+ )
51
+ .addPrimaryKeyConstraint('pikku_role_scopes_pk', ['role', 'scope']),
52
+
53
+ (db, types) =>
54
+ db.schema
55
+ .createTable('pikkuUserRole')
56
+ .addColumn('userId', requiredType(types, 'user', 'id'), (col) =>
57
+ col.notNull().references('user.id').onDelete('cascade')
58
+ )
59
+ .addColumn('role', 'text', (col) =>
60
+ col.notNull().references('pikkuRoles.name').onDelete('cascade')
61
+ )
62
+ .addColumn('grantedBy', 'text')
63
+ .addColumn('grantedAt', 'timestamp', (col) =>
64
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
65
+ )
66
+ .addPrimaryKeyConstraint('pikku_user_role_pk', ['userId', 'role']),
67
+
68
+ (db, types) =>
69
+ db.schema
70
+ .createTable('pikkuUserScope')
71
+ .addColumn('userId', requiredType(types, 'user', 'id'), (col) =>
72
+ col.notNull().references('user.id').onDelete('cascade')
73
+ )
74
+ .addColumn('scope', 'text', (col) =>
75
+ col.notNull().references('pikkuScopes.name').onDelete('cascade')
76
+ )
77
+ .addColumn('grantedBy', 'text')
78
+ .addColumn('grantedAt', 'timestamp', (col) =>
79
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
80
+ )
81
+ .addPrimaryKeyConstraint('pikku_user_scope_pk', ['userId', 'scope']),
82
+
83
+ (db) =>
84
+ db.schema
85
+ .createIndex('pikku_role_scopes_scope_idx')
86
+ .on('pikkuRoleScopes')
87
+ .column('scope'),
88
+
89
+ (db) =>
90
+ db.schema
91
+ .createIndex('pikku_user_role_role_idx')
92
+ .on('pikkuUserRole')
93
+ .column('role'),
94
+ ],
95
+ }
@@ -0,0 +1,40 @@
1
+ import { sql } from 'kysely'
2
+ import type { PikkuSchema } from './pikku-schema.types.js'
3
+
4
+ /**
5
+ * Encrypted secrets and their audit trail.
6
+ *
7
+ * The audit table is declared unconditionally, where `init()` created it only
8
+ * when the service was constructed with auditing on. A migration cannot depend
9
+ * on runtime configuration, and the asymmetry favours declaring it: an unused
10
+ * table costs nothing, while turning auditing on against a database that never
11
+ * got the table fails on the first write.
12
+ */
13
+ export const secretSchema: PikkuSchema = {
14
+ name: 'secret',
15
+ statements: [
16
+ (db) =>
17
+ db.schema
18
+ .createTable('secrets')
19
+ .addColumn('key', 'varchar(255)', (col) => col.primaryKey())
20
+ .addColumn('ciphertext', 'text', (col) => col.notNull())
21
+ .addColumn('wrappedDek', 'text', (col) => col.notNull())
22
+ .addColumn('keyVersion', 'integer', (col) => col.notNull())
23
+ .addColumn('createdAt', 'timestamp', (col) =>
24
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
25
+ )
26
+ .addColumn('updatedAt', 'timestamp', (col) =>
27
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
28
+ ),
29
+
30
+ (db) =>
31
+ db.schema
32
+ .createTable('secretsAudit')
33
+ .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
34
+ .addColumn('secretKey', 'varchar(255)', (col) => col.notNull())
35
+ .addColumn('action', 'varchar(20)', (col) => col.notNull())
36
+ .addColumn('performedAt', 'timestamp', (col) =>
37
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
38
+ ),
39
+ ],
40
+ }
@@ -0,0 +1,20 @@
1
+ import { sql } from 'kysely'
2
+ import type { PikkuSchema } from './pikku-schema.types.js'
3
+
4
+ /** Server-side session storage. */
5
+ export const sessionSchema: PikkuSchema = {
6
+ name: 'session',
7
+ statements: [
8
+ (db) =>
9
+ db.schema
10
+ .createTable('pikkuUserSessions')
11
+ .addColumn('pikkuUserId', 'text', (col) => col.primaryKey())
12
+ .addColumn('session', 'text', (col) => col.notNull())
13
+ .addColumn('createdAt', 'timestamp', (col) =>
14
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
15
+ )
16
+ .addColumn('updatedAt', 'timestamp', (col) =>
17
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
18
+ ),
19
+ ],
20
+ }
@@ -0,0 +1,57 @@
1
+ import { sql } from 'kysely'
2
+ import type { PikkuSchema } from './pikku-schema.types.js'
3
+
4
+ /** Webhook deliveries and one row per delivery attempt. */
5
+ export const webhookSchema: PikkuSchema = {
6
+ name: 'webhook',
7
+ statements: [
8
+ (db) =>
9
+ db.schema
10
+ .createTable('webhookDelivery')
11
+ .addColumn('deliveryId', 'text', (col) => col.primaryKey())
12
+ .addColumn('organizationId', 'text')
13
+ .addColumn('url', 'text', (col) => col.notNull())
14
+ .addColumn('event', 'text')
15
+ .addColumn('status', 'text', (col) =>
16
+ col.defaultTo('pending').notNull()
17
+ )
18
+ .addColumn('attempts', 'integer', (col) => col.defaultTo(0).notNull())
19
+ .addColumn('createdAt', 'timestamp', (col) =>
20
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
21
+ )
22
+ .addColumn('updatedAt', 'timestamp', (col) =>
23
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
24
+ )
25
+ .addColumn('deliveredAt', 'timestamp'),
26
+
27
+ (db) =>
28
+ db.schema
29
+ .createTable('webhookDeliveryAttempt')
30
+ .addColumn('attemptId', 'text', (col) => col.primaryKey())
31
+ .addColumn('deliveryId', 'text', (col) =>
32
+ col
33
+ .notNull()
34
+ .references('webhookDelivery.deliveryId')
35
+ .onDelete('cascade')
36
+ )
37
+ .addColumn('attemptNumber', 'integer', (col) => col.notNull())
38
+ .addColumn('statusCode', 'integer')
39
+ .addColumn('responseBody', 'text')
40
+ .addColumn('error', 'text')
41
+ .addColumn('createdAt', 'timestamp', (col) =>
42
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
43
+ ),
44
+
45
+ (db) =>
46
+ db.schema
47
+ .createIndex('idx_webhook_delivery_org')
48
+ .on('webhookDelivery')
49
+ .column('organizationId'),
50
+
51
+ (db) =>
52
+ db.schema
53
+ .createIndex('idx_webhook_delivery_attempt_delivery')
54
+ .on('webhookDeliveryAttempt')
55
+ .column('deliveryId'),
56
+ ],
57
+ }
@@ -0,0 +1,113 @@
1
+ import { sql } from 'kysely'
2
+ import type { PikkuSchema } from './pikku-schema.types.js'
3
+
4
+ /**
5
+ * Workflow runs, their steps, per-step history and the versioned graphs.
6
+ *
7
+ * Two things from the boot-time DDL are deliberately not carried over. The
8
+ * primary keys had `defaultTo(sql.raw("'" + crypto.randomUUID() + "'"))`, which
9
+ * evaluates once while the statement is built — every row would have taken the
10
+ * same default, so it was never a generator; the services supply the id. And
11
+ * `workflowStep.fromStepName` was backfilled by an `alterTable(...).catch(() =>
12
+ * {})` bolted on after the fact, which is the job a migration does.
13
+ *
14
+ * `KyselyWorkflowMirror` created these same four tables separately. There is one
15
+ * declaration now, and both services read it.
16
+ */
17
+ export const workflowSchema: PikkuSchema = {
18
+ name: 'workflow',
19
+ statements: [
20
+ (db) =>
21
+ db.schema
22
+ .createTable('workflowRuns')
23
+ .addColumn('workflowRunId', 'text', (col) => col.primaryKey())
24
+ .addColumn('workflow', 'text', (col) => col.notNull())
25
+ .addColumn('status', 'text', (col) => col.notNull())
26
+ .addColumn('input', 'text', (col) => col.notNull())
27
+ .addColumn('output', 'text')
28
+ .addColumn('error', 'text')
29
+ .addColumn('state', 'text', (col) => col.defaultTo('{}'))
30
+ .addColumn('inline', 'boolean', (col) => col.defaultTo(false))
31
+ .addColumn('graphHash', 'text')
32
+ .addColumn('deterministic', 'boolean', (col) => col.defaultTo(false))
33
+ .addColumn('plannedSteps', 'text')
34
+ .addColumn('wire', 'text')
35
+ .addColumn('createdAt', 'timestamp', (col) =>
36
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
37
+ )
38
+ .addColumn('updatedAt', 'timestamp', (col) =>
39
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
40
+ ),
41
+
42
+ (db) =>
43
+ db.schema
44
+ .createTable('workflowStep')
45
+ .addColumn('workflowStepId', 'text', (col) => col.primaryKey())
46
+ .addColumn('workflowRunId', 'text', (col) =>
47
+ col
48
+ .notNull()
49
+ .references('workflowRuns.workflowRunId')
50
+ .onDelete('cascade')
51
+ )
52
+ .addColumn('stepName', 'text', (col) => col.notNull())
53
+ .addColumn('rpcName', 'text')
54
+ .addColumn('data', 'text')
55
+ .addColumn('status', 'text', (col) =>
56
+ col.notNull().defaultTo('pending')
57
+ )
58
+ .addColumn('result', 'text')
59
+ .addColumn('error', 'text')
60
+ .addColumn('childRunId', 'text')
61
+ .addColumn('branchTaken', 'text')
62
+ .addColumn('retries', 'integer')
63
+ .addColumn('retryDelay', 'text')
64
+ .addColumn('fromStepName', 'text')
65
+ .addColumn('createdAt', 'timestamp', (col) =>
66
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
67
+ )
68
+ .addColumn('updatedAt', 'timestamp', (col) =>
69
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
70
+ )
71
+ .addUniqueConstraint('workflow_step_run_name_unique', [
72
+ 'workflowRunId',
73
+ 'stepName',
74
+ ]),
75
+
76
+ (db) =>
77
+ db.schema
78
+ .createTable('workflowStepHistory')
79
+ .addColumn('historyId', 'text', (col) => col.primaryKey())
80
+ .addColumn('workflowStepId', 'text', (col) =>
81
+ col
82
+ .notNull()
83
+ .references('workflowStep.workflowStepId')
84
+ .onDelete('cascade')
85
+ )
86
+ .addColumn('status', 'text', (col) => col.notNull())
87
+ .addColumn('result', 'text')
88
+ .addColumn('error', 'text')
89
+ .addColumn('createdAt', 'timestamp', (col) =>
90
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
91
+ )
92
+ .addColumn('runningAt', 'timestamp')
93
+ .addColumn('scheduledAt', 'timestamp')
94
+ .addColumn('succeededAt', 'timestamp')
95
+ .addColumn('failedAt', 'timestamp'),
96
+
97
+ (db) =>
98
+ db.schema
99
+ .createTable('workflowVersions')
100
+ .addColumn('workflowName', 'text', (col) => col.notNull())
101
+ .addColumn('graphHash', 'text', (col) => col.notNull())
102
+ .addColumn('graph', 'text', (col) => col.notNull())
103
+ .addColumn('source', 'text', (col) => col.notNull())
104
+ .addColumn('status', 'text', (col) => col.notNull().defaultTo('active'))
105
+ .addColumn('createdAt', 'timestamp', (col) =>
106
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
107
+ )
108
+ .addPrimaryKeyConstraint('workflow_versions_pk', [
109
+ 'workflowName',
110
+ 'graphHash',
111
+ ]),
112
+ ],
113
+ }