@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,37 @@
1
+ import { sql } from 'kysely'
2
+ import type { PikkuSchema } from './pikku-schema.types.js'
3
+
4
+ /** Channels and their topic subscriptions, also read by the event-hub store. */
5
+ export const channelSchema: PikkuSchema = {
6
+ name: 'channel',
7
+ statements: [
8
+ (db) =>
9
+ db.schema
10
+ .createTable('channels')
11
+ .addColumn('channelId', 'text', (col) => col.primaryKey())
12
+ .addColumn('channelName', 'text', (col) => col.notNull())
13
+ .addColumn('createdAt', 'timestamp', (col) =>
14
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
15
+ )
16
+ .addColumn('openingData', 'text', (col) =>
17
+ col.notNull().defaultTo('{}')
18
+ )
19
+ .addColumn('pikkuUserId', 'text')
20
+ .addColumn('state', 'text')
21
+ .addColumn('lastWire', 'timestamp', (col) =>
22
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
23
+ ),
24
+
25
+ (db) =>
26
+ db.schema
27
+ .createTable('channelSubscriptions')
28
+ .addColumn('channelId', 'text', (col) =>
29
+ col.notNull().references('channels.channelId').onDelete('cascade')
30
+ )
31
+ .addColumn('topic', 'text', (col) => col.notNull())
32
+ .addPrimaryKeyConstraint('channel_subscriptions_pk', [
33
+ 'channelId',
34
+ 'topic',
35
+ ]),
36
+ ],
37
+ }
@@ -0,0 +1,46 @@
1
+ import { sql } from 'kysely'
2
+ import { rawStatement, type PikkuSchema } from './pikku-schema.types.js'
3
+
4
+ /**
5
+ * Encrypted per-user credentials and their audit trail.
6
+ *
7
+ * The uniqueness rule is an expression index — `user_id` is nullable and two
8
+ * rows with a null user must still collide on name — which the schema builder
9
+ * cannot express, so it stays raw. Raw SQL is not rewritten by `withSchema`,
10
+ * which is why the table is named explicitly rather than left to the search
11
+ * path.
12
+ */
13
+ export const credentialSchema: PikkuSchema = {
14
+ name: 'credential',
15
+ statements: [
16
+ (db) =>
17
+ db.schema
18
+ .createTable('credentials')
19
+ .addColumn('name', 'varchar(255)', (col) => col.notNull())
20
+ .addColumn('userId', 'varchar(255)')
21
+ .addColumn('ciphertext', 'text', (col) => col.notNull())
22
+ .addColumn('wrappedDek', 'text', (col) => col.notNull())
23
+ .addColumn('keyVersion', 'integer', (col) => col.notNull())
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
+ rawStatement(
32
+ sql`CREATE UNIQUE INDEX credentials_name_user_id_unique ON credentials (name, COALESCE(user_id, ''))`
33
+ ),
34
+
35
+ (db) =>
36
+ db.schema
37
+ .createTable('credentialsAudit')
38
+ .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
39
+ .addColumn('credentialName', 'varchar(255)', (col) => col.notNull())
40
+ .addColumn('userId', 'varchar(255)')
41
+ .addColumn('action', 'varchar(20)', (col) => col.notNull())
42
+ .addColumn('performedAt', 'timestamp', (col) =>
43
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
44
+ ),
45
+ ],
46
+ }
@@ -0,0 +1,47 @@
1
+ import { sql } from 'kysely'
2
+ import type { PikkuSchema } from './pikku-schema.types.js'
3
+
4
+ /** Registered deployments and the functions each one serves. */
5
+ export const deploymentSchema: PikkuSchema = {
6
+ name: 'deployment',
7
+ statements: [
8
+ (db) =>
9
+ db.schema
10
+ .createTable('pikkuDeployments')
11
+ .addColumn('deploymentId', 'text', (col) => col.primaryKey())
12
+ .addColumn('endpoint', 'text', (col) => col.notNull())
13
+ .addColumn('lastHeartbeat', 'timestamp', (col) =>
14
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
15
+ )
16
+ .addColumn('createdAt', 'timestamp', (col) =>
17
+ col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
18
+ ),
19
+
20
+ (db) =>
21
+ db.schema
22
+ .createTable('pikkuDeploymentFunctions')
23
+ .addColumn('deploymentId', 'text', (col) =>
24
+ col
25
+ .notNull()
26
+ .references('pikkuDeployments.deploymentId')
27
+ .onDelete('cascade')
28
+ )
29
+ .addColumn('functionName', 'text', (col) => col.notNull())
30
+ .addPrimaryKeyConstraint('pikku_deployment_functions_pk', [
31
+ 'deploymentId',
32
+ 'functionName',
33
+ ]),
34
+
35
+ (db) =>
36
+ db.schema
37
+ .createIndex('idx_pikku_deployments_heartbeat')
38
+ .on('pikkuDeployments')
39
+ .column('lastHeartbeat'),
40
+
41
+ (db) =>
42
+ db.schema
43
+ .createIndex('idx_pikku_deployment_functions_name')
44
+ .on('pikkuDeploymentFunctions')
45
+ .column('functionName'),
46
+ ],
47
+ }
@@ -0,0 +1,249 @@
1
+ import { CamelCasePlugin, type Kysely } from 'kysely'
2
+ import type {
3
+ PikkuSchema,
4
+ RequiredTypes,
5
+ SchemaRequirement,
6
+ } from './pikku-schema.types.js'
7
+ import { aiSchema } from './ai.schema.js'
8
+ import { channelSchema } from './channel.schema.js'
9
+ import { credentialSchema } from './credential.schema.js'
10
+ import { deploymentSchema } from './deployment.schema.js'
11
+ import { scopeSchema } from './scope.schema.js'
12
+ import { secretSchema } from './secret.schema.js'
13
+ import { sessionSchema } from './session.schema.js'
14
+ import { webhookSchema } from './webhook.schema.js'
15
+ import { workflowSchema } from './workflow.schema.js'
16
+
17
+ export type {
18
+ PikkuSchema,
19
+ RequiredTypes,
20
+ SchemaRequirement,
21
+ SchemaStatement,
22
+ SchemaStatementFactory,
23
+ } from './pikku-schema.types.js'
24
+ export { rawStatement, requiredType } from './pikku-schema.types.js'
25
+
26
+ /**
27
+ * Every table the pikku runtime needs, in dependency order.
28
+ *
29
+ * Order is load-bearing, not cosmetic: `scope` has foreign keys onto Better
30
+ * Auth's `user`, and the tables within each schema reference each other. Sort
31
+ * this list alphabetically and it stops applying.
32
+ */
33
+ export const pikkuSchemas: PikkuSchema[] = [
34
+ channelSchema,
35
+ sessionSchema,
36
+ secretSchema,
37
+ credentialSchema,
38
+ deploymentSchema,
39
+ webhookSchema,
40
+ workflowSchema,
41
+ aiSchema,
42
+ scopeSchema,
43
+ ]
44
+
45
+ /**
46
+ * Bind a declaration to a database.
47
+ *
48
+ * The plugin is applied here rather than trusted to the caller. Declarations
49
+ * name tables and columns the way the rest of the codebase queries them
50
+ * (`aiThreads.resourceId`), and it is `CamelCasePlugin` that turns those into
51
+ * the physical `ai_threads.resource_id`. Without it the same declaration
52
+ * silently creates quoted camelCase tables that nothing can read.
53
+ */
54
+ const bind = (db: Kysely<any>) => db.withPlugin(new CamelCasePlugin())
55
+
56
+ export interface UnmetRequirement {
57
+ schema: PikkuSchema
58
+ requirement: SchemaRequirement
59
+ }
60
+
61
+ /**
62
+ * Look up every prerequisite in `db`, reporting the gaps and the types found.
63
+ *
64
+ * Both halves come from one introspection because they are one question. The
65
+ * gaps say whether a schema can be applied at all; the types say how — a
66
+ * foreign key onto a `uuid` primary key has to be declared `uuid`, and guessing
67
+ * `text` produces DDL the database refuses.
68
+ *
69
+ * Introspection returns physical names, so `requires` is written in physical
70
+ * names too — this is the one place in the declaration that is not camelCase.
71
+ */
72
+ export const resolveRequirements = async (
73
+ db: Kysely<any>,
74
+ schemas: PikkuSchema[] = pikkuSchemas
75
+ ): Promise<{ types: RequiredTypes; unmet: UnmetRequirement[] }> => {
76
+ const tables = new Map(
77
+ (await bind(db).introspection.getTables()).map((table) => [
78
+ table.name,
79
+ table,
80
+ ])
81
+ )
82
+
83
+ const types: RequiredTypes = {}
84
+ const unmet: UnmetRequirement[] = []
85
+ for (const schema of schemas) {
86
+ for (const requirement of schema.requires ?? []) {
87
+ const column = tables
88
+ .get(requirement.table)
89
+ ?.columns.find((c) => c.name === requirement.column)
90
+ if (!column) {
91
+ unmet.push({ schema, requirement })
92
+ } else {
93
+ types[`${requirement.table}.${requirement.column}`] = column.dataType
94
+ }
95
+ }
96
+ }
97
+ return { types, unmet }
98
+ }
99
+
100
+ /**
101
+ * The prerequisites `db` does not satisfy, as a list rather than a throw.
102
+ *
103
+ * Separate from `applyPikkuSchemas` because the two callers want opposite
104
+ * things from the same answer: a service booting cannot proceed without its
105
+ * tables, while a tool asking what a project's schema *would* be needs to
106
+ * describe the gap rather than die on it.
107
+ */
108
+ export const unsatisfiedRequirements = async (
109
+ db: Kysely<any>,
110
+ schemas: PikkuSchema[] = pikkuSchemas
111
+ ): Promise<UnmetRequirement[]> => (await resolveRequirements(db, schemas)).unmet
112
+
113
+ /**
114
+ * Create the tables, in order, on a database that has whatever they require.
115
+ *
116
+ * Prerequisites are checked before anything is created, so a project missing
117
+ * one is told which schema wanted what — rather than being handed a foreign key
118
+ * error, or a half-applied database to clean up.
119
+ *
120
+ * The statements then run in one transaction, so the same promise holds for a
121
+ * failure the check cannot foresee: a statement that dies mid-list takes the
122
+ * ones before it with it. On an engine without transactional DDL the rollback
123
+ * is the engine's to give, not ours.
124
+ */
125
+ export const applyPikkuSchemas = async (
126
+ db: Kysely<any>,
127
+ schemas: PikkuSchema[] = pikkuSchemas
128
+ ): Promise<void> => {
129
+ const { types, unmet } = await resolveRequirements(db, schemas)
130
+ if (unmet.length > 0) {
131
+ throw new Error(
132
+ unmet
133
+ .map(
134
+ ({ schema, requirement }) =>
135
+ `The '${schema.name}' schema requires '${requirement.table}.${requirement.column}', which nothing has created. ` +
136
+ `${requirement.owner} owns it — apply its schema first.`
137
+ )
138
+ .join('\n')
139
+ )
140
+ }
141
+
142
+ await db.transaction().execute(async (trx) => {
143
+ const bound = bind(trx)
144
+ for (const schema of schemas) {
145
+ for (const statement of schema.statements) {
146
+ await statement(bound, types).execute()
147
+ }
148
+ }
149
+ })
150
+ }
151
+
152
+ /**
153
+ * The tables a schema creates, read back out of its own compiled SQL.
154
+ *
155
+ * Derived rather than declared alongside the statements, because a hand-kept
156
+ * list of names is a second source of truth: rename a table in a statement and
157
+ * the list keeps answering for the old one.
158
+ */
159
+ const declaredTables = (schema: PikkuSchema, db: Kysely<any>): string[] => {
160
+ const bound = bind(db)
161
+ const tables: string[] = []
162
+ for (const statement of schema.statements) {
163
+ const match = /^\s*create table "([^"]+)"/i.exec(
164
+ statement(bound, {}).compile().sql
165
+ )
166
+ if (match) tables.push(match[1]!)
167
+ }
168
+ return tables
169
+ }
170
+
171
+ /** What `ensurePikkuSchema` found when it looked. */
172
+ export type EnsureOutcome = 'present' | 'created'
173
+
174
+ const tablesOf = async (db: Kysely<any>): Promise<Set<string>> =>
175
+ new Set((await bind(db).introspection.getTables()).map((table) => table.name))
176
+
177
+ /**
178
+ * Make sure one schema's tables are there, creating them only if none are.
179
+ *
180
+ * What a service calls at boot. It looks before it creates, which is the whole
181
+ * difference from the `.ifNotExists()` DDL this replaces: that spelling turned
182
+ * every failure into a silent no-op, and hid a foreign key onto `user.id` that
183
+ * postgres had been rejecting since the day it was written.
184
+ *
185
+ * A partially-present schema is refused rather than completed. Half a schema
186
+ * means something else already applied part of it — a migration, an older
187
+ * version, a hand-run script — and creating the remainder at boot would leave
188
+ * two authorities over one set of tables, which is the condition all of this
189
+ * exists to end.
190
+ *
191
+ * Creating at boot is the fallback, not the intent. `pikku db generate` writes
192
+ * the declaration down as a migration; a project that has done so takes the
193
+ * `present` path and this never issues DDL at all.
194
+ */
195
+ export const ensurePikkuSchema = async (
196
+ db: Kysely<any>,
197
+ schema: PikkuSchema
198
+ ): Promise<EnsureOutcome> => {
199
+ const existing = await tablesOf(db)
200
+ const declared = declaredTables(schema, db)
201
+ const missing = declared.filter((table) => !existing.has(table))
202
+
203
+ if (missing.length === 0) return 'present'
204
+
205
+ if (missing.length < declared.length) {
206
+ throw new Error(
207
+ `The '${schema.name}' schema is half applied: ${missing.join(', ')} missing, ` +
208
+ `${declared.filter((table) => existing.has(table)).join(', ')} already there. ` +
209
+ 'Something else owns part of it. Reconcile it with a migration — ' +
210
+ '`pikku db generate` writes the declaration down — rather than creating the rest at boot.'
211
+ )
212
+ }
213
+
214
+ try {
215
+ await applyPikkuSchemas(db, [schema])
216
+ return 'created'
217
+ } catch (error) {
218
+ // Two instances booting cold against one database can both see it empty
219
+ // before either has finished creating. If the other one got there, say so;
220
+ // anything else is a real failure and still throws. This is a courtesy, not
221
+ // a guarantee — a boot race is only fully answered by having run the
222
+ // migration, which is what `pikku db generate` is for.
223
+ const after = await tablesOf(db)
224
+ if (declared.every((table) => after.has(table))) return 'present'
225
+ throw error
226
+ }
227
+ }
228
+
229
+ /**
230
+ * Render the declaration as SQL for the dialect `db` was built with.
231
+ *
232
+ * `types` comes from `resolveRequirements` against the database the migration
233
+ * is being written for. Omit it and foreign keys onto another source's columns
234
+ * compile as `text`, which is a shape, not a migration.
235
+ */
236
+ export const compilePikkuSchemas = (
237
+ db: Kysely<any>,
238
+ schemas: PikkuSchema[] = pikkuSchemas,
239
+ types: RequiredTypes = {}
240
+ ): string => {
241
+ const bound = bind(db)
242
+ return schemas
243
+ .flatMap((schema) =>
244
+ schema.statements.map(
245
+ (statement) => `${statement(bound, types).compile().sql};`
246
+ )
247
+ )
248
+ .join('\n\n')
249
+ }
@@ -0,0 +1,94 @@
1
+ import { sql, type CompiledQuery, type Kysely, type RawBuilder } from 'kysely'
2
+
3
+ /**
4
+ * One DDL statement, bound to a database.
5
+ *
6
+ * Bound rather than free-standing because the same declaration serves two
7
+ * purposes that need the same object: applied to a throwaway database it yields
8
+ * the schema to introspect, and compiled against a dialect it yields the SQL a
9
+ * migration is written from.
10
+ */
11
+ export interface SchemaStatement {
12
+ execute(): Promise<unknown>
13
+ compile(): CompiledQuery
14
+ }
15
+
16
+ export type SchemaStatementFactory = (
17
+ db: Kysely<any>,
18
+ types: RequiredTypes
19
+ ) => SchemaStatement
20
+
21
+ /**
22
+ * The physical types of the columns named in `requires`, keyed `table.column`.
23
+ *
24
+ * Introspected from the database the schemas are being applied to, because a
25
+ * foreign key column has to be declared with the type of the column it points
26
+ * at and that column belongs to somebody else.
27
+ */
28
+ export type RequiredTypes = Record<string, string>
29
+
30
+ /**
31
+ * The declared type for a foreign key onto a column another source owns.
32
+ *
33
+ * Falls back to `text` when nothing has been introspected — the case when the
34
+ * declaration is compiled without a database, where the real type is unknowable
35
+ * and only the shape of the statement is being inspected.
36
+ */
37
+ export const requiredType = (
38
+ types: RequiredTypes,
39
+ table: string,
40
+ column: string
41
+ ): RawBuilder<unknown> => sql.raw(types[`${table}.${column}`] ?? 'text')
42
+
43
+ /**
44
+ * The tables one part of the pikku runtime needs, declared rather than created.
45
+ *
46
+ * `name` labels the generated migration and the drift report, so it is part of
47
+ * the contract — changing it orphans the migration that was written under the
48
+ * old one.
49
+ */
50
+ export interface PikkuSchema {
51
+ name: string
52
+ statements: SchemaStatementFactory[]
53
+ /**
54
+ * Tables this schema references but does not create, which something else
55
+ * must have created first.
56
+ *
57
+ * Declared rather than discovered so the failure arrives as a sentence naming
58
+ * what is missing and who owns it, instead of a foreign key error from the
59
+ * database — or, worse, a stub table conjured up to make the error go away.
60
+ */
61
+ requires?: SchemaRequirement[]
62
+ }
63
+
64
+ /**
65
+ * A column another source owns, which one of these schemas depends on.
66
+ *
67
+ * Named down to the column, not just the table, because that is the granularity
68
+ * the dependency actually has: a foreign key needs the referenced column to
69
+ * exist *and* needs its type in order to declare a matching one.
70
+ *
71
+ * `owner` is carried in the declaration rather than composed into the error at
72
+ * the point it is thrown, so adding a second prerequisite cannot leave behind a
73
+ * message that still names the first one's owner.
74
+ */
75
+ export interface SchemaRequirement {
76
+ table: string
77
+ column: string
78
+ owner: string
79
+ }
80
+
81
+ /**
82
+ * Wrap a raw SQL fragment as a statement.
83
+ *
84
+ * For the DDL kysely's schema builder cannot express — a unique index over an
85
+ * expression, say. Note that raw SQL is *not* rewritten by `withSchema`, so a
86
+ * fragment naming a table unqualified will resolve against the connection's
87
+ * search path rather than the schema the rest of the declaration lands in.
88
+ */
89
+ export const rawStatement =
90
+ (fragment: RawBuilder<unknown>): SchemaStatementFactory =>
91
+ (db) => ({
92
+ execute: () => fragment.execute(db),
93
+ compile: () => fragment.compile(db),
94
+ })