@pikku/kysely 0.13.3 → 0.13.5

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 +52 -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 +99 -0
  29. package/dist/src/schema/index.js +274 -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 +374 -0
  64. package/src/schema/pikku-schema.types.ts +94 -0
  65. package/src/schema/schema.test.ts +456 -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,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
+ })
@@ -0,0 +1,456 @@
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
+ declaredTables,
21
+ unsatisfiedRequirements,
22
+ type PikkuSchema,
23
+ } from './index.js'
24
+ import { workflowSchema } from './workflow.schema.js'
25
+
26
+ const dialect = (kind: 'postgres' | 'sqlite') =>
27
+ kind === 'postgres'
28
+ ? {
29
+ createAdapter: () => new PostgresAdapter(),
30
+ createDriver: () => new DummyDriver(),
31
+ createIntrospector: (db: Kysely<any>) => new PostgresIntrospector(db),
32
+ createQueryCompiler: () => new PostgresQueryCompiler(),
33
+ }
34
+ : {
35
+ createAdapter: () => new SqliteAdapter(),
36
+ createDriver: () => new DummyDriver(),
37
+ createIntrospector: (db: Kysely<any>) => new SqliteIntrospector(db),
38
+ createQueryCompiler: () => new SqliteQueryCompiler(),
39
+ }
40
+
41
+ /** A driver whose every query fails with `error` — for testing the failure path. */
42
+ class ThrowingDriver extends DummyDriver {
43
+ constructor(private readonly error: Error) {
44
+ super()
45
+ }
46
+ override async acquireConnection() {
47
+ return {
48
+ executeQuery: async (): Promise<never> => {
49
+ throw this.error
50
+ },
51
+ streamQuery: async function* () {},
52
+ }
53
+ }
54
+ }
55
+
56
+ const compileFor = (kind: 'postgres' | 'sqlite') =>
57
+ compilePikkuSchemas(new Kysely<any>({ dialect: dialect(kind) }))
58
+
59
+ describe('pikku runtime schema', () => {
60
+ test('declares logical names and compiles them to physical ones', () => {
61
+ const sql = compileFor('postgres')
62
+
63
+ // The declaration says `aiThreads.resourceId`, matching how every query in
64
+ // the package addresses it; the plugin is what makes the table `ai_threads`.
65
+ assert.match(sql, /create table "ai_threads"/)
66
+ assert.match(sql, /"resource_id" varchar\(255\) not null/)
67
+ assert.doesNotMatch(
68
+ sql,
69
+ /"aiThreads"/,
70
+ 'a camelCase table name means the plugin was not applied'
71
+ )
72
+ })
73
+
74
+ test('rewrites foreign key targets too', () => {
75
+ assert.match(
76
+ compileFor('postgres'),
77
+ /references "ai_threads" \("id"\)/,
78
+ 'an unrewritten reference target points at a table that does not exist'
79
+ )
80
+ })
81
+
82
+ test('compiles for every dialect from the one declaration', () => {
83
+ for (const kind of ['postgres', 'sqlite'] as const) {
84
+ const sql = compileFor(kind)
85
+ assert.match(sql, /create table "workflow_runs"/, `${kind} workflow_runs`)
86
+ assert.match(sql, /create table "pikku_scopes"/, `${kind} pikku_scopes`)
87
+ }
88
+ })
89
+
90
+ test('carries the ai_run column the two old declarations disagreed on', () => {
91
+ assert.match(compileFor('postgres'), /"pending_approvals" text/)
92
+ })
93
+
94
+ test('creates tables outright rather than if-not-exists', () => {
95
+ // A migration runs once. `if not exists` would let a table that already
96
+ // exists in a different shape pass silently, which is the drift the
97
+ // migration is supposed to surface.
98
+ assert.doesNotMatch(compileFor('postgres'), /create table if not exists/)
99
+ })
100
+
101
+ test('drops the frozen uuid default from workflow primary keys', () => {
102
+ // It was `sql.raw("'" + crypto.randomUUID() + "'")`, evaluated once when the
103
+ // statement was built, so every row shared one default.
104
+ assert.match(
105
+ compileFor('postgres'),
106
+ /"workflow_run_id" text primary key,/,
107
+ 'the primary key should carry no default at all'
108
+ )
109
+ assert.match(compileFor('postgres'), /"workflow_step_id" text primary key,/)
110
+ assert.match(compileFor('postgres'), /"history_id" text primary key,/)
111
+ })
112
+
113
+ test('every schema is named, for the migration it will be written into', () => {
114
+ const names = pikkuSchemas.map((s) => s.name)
115
+ assert.equal(new Set(names).size, names.length, 'names must be unique')
116
+ assert.ok(names.every((n) => /^[a-z][a-z-]*$/.test(n)))
117
+ })
118
+ })
119
+
120
+ describe('pikku runtime schema — prerequisites', () => {
121
+ const memoryDb = () =>
122
+ new Kysely<any>({
123
+ dialect: new SqliteDialect({ database: new Database(':memory:') }),
124
+ })
125
+
126
+ test('names the missing table and who owns it, before creating anything', async () => {
127
+ const db = memoryDb()
128
+ try {
129
+ await assert.rejects(
130
+ applyPikkuSchemas(db),
131
+ /The 'scope' schema requires 'user\.id', which nothing has created\. Better Auth owns it/
132
+ )
133
+
134
+ const created = await db.introspection.getTables()
135
+ assert.deepEqual(
136
+ created.map((t) => t.name),
137
+ [],
138
+ 'a failed prerequisite must not leave a half-applied database'
139
+ )
140
+ } finally {
141
+ await db.destroy()
142
+ }
143
+ })
144
+
145
+ test('applies in full once the prerequisite is there', async () => {
146
+ const db = memoryDb()
147
+ try {
148
+ await db.schema
149
+ .createTable('user')
150
+ .addColumn('id', 'text', (col) => col.primaryKey())
151
+ .execute()
152
+
153
+ assert.deepEqual(await unsatisfiedRequirements(db), [])
154
+ await applyPikkuSchemas(db)
155
+
156
+ const created = new Set(
157
+ (await db.introspection.getTables()).map((t) => t.name)
158
+ )
159
+ assert.ok(created.has('pikku_user_role'))
160
+ assert.ok(created.has('workflow_runs'))
161
+ } finally {
162
+ await db.destroy()
163
+ }
164
+ })
165
+
166
+ test('takes the grant column type from the user id it references', () => {
167
+ // The hand-written DDL said `text`. Better Auth generates a `uuid` primary
168
+ // key on postgres, and postgres rejects a text column referencing it —
169
+ // which is why projects ended up writing these tables by hand.
170
+ const sql = compilePikkuSchemas(
171
+ new Kysely<any>({ dialect: dialect('postgres') }),
172
+ pikkuSchemas,
173
+ { 'user.id': 'uuid' }
174
+ )
175
+ assert.match(
176
+ sql,
177
+ /create table "pikku_user_role" \("user_id" uuid not null references "user" \("id"\)/
178
+ )
179
+ assert.match(
180
+ sql,
181
+ /create table "pikku_user_scope" \("user_id" uuid not null references "user" \("id"\)/
182
+ )
183
+ })
184
+
185
+ test('reports the gap as data, so a caller can describe it instead of dying', async () => {
186
+ const db = memoryDb()
187
+ try {
188
+ const unmet = await unsatisfiedRequirements(db)
189
+ assert.deepEqual(
190
+ unmet.map(({ schema, requirement }) => ({
191
+ schema: schema.name,
192
+ ...requirement,
193
+ })),
194
+ [{ schema: 'scope', table: 'user', column: 'id', owner: 'Better Auth' }]
195
+ )
196
+
197
+ // The rest of the declaration does not depend on auth, so it still applies.
198
+ const satisfiable = pikkuSchemas.filter((s) => s.name !== 'scope')
199
+ await applyPikkuSchemas(db, satisfiable)
200
+ const created = new Set(
201
+ (await db.introspection.getTables()).map((t) => t.name)
202
+ )
203
+ assert.ok(created.has('workflow_runs'))
204
+ assert.equal(created.has('pikku_user_role'), false)
205
+ } finally {
206
+ await db.destroy()
207
+ }
208
+ })
209
+
210
+ test('a statement failing part way takes the ones before it with it', async () => {
211
+ const db = memoryDb()
212
+ try {
213
+ await db.schema.createTable('taken').addColumn('id', 'text').execute()
214
+
215
+ // The prerequisite check cannot see this coming: nothing is missing, the
216
+ // second statement simply collides with a table the database already has.
217
+ const collides: PikkuSchema = {
218
+ name: 'collides',
219
+ statements: [
220
+ (db) => db.schema.createTable('first').addColumn('id', 'text'),
221
+ (db) => db.schema.createTable('taken').addColumn('id', 'text'),
222
+ ],
223
+ }
224
+
225
+ await assert.rejects(applyPikkuSchemas(db, [collides]))
226
+
227
+ const created = new Set(
228
+ (await db.introspection.getTables()).map((t) => t.name)
229
+ )
230
+ assert.equal(
231
+ created.has('first'),
232
+ false,
233
+ 'the statement that succeeded must roll back with the one that did not'
234
+ )
235
+ } finally {
236
+ await db.destroy()
237
+ }
238
+ })
239
+ })
240
+
241
+ describe('ensurePikkuSchema — what a service does at boot', () => {
242
+ const memoryDb = () =>
243
+ new Kysely<any>({
244
+ dialect: new SqliteDialect({ database: new Database(':memory:') }),
245
+ })
246
+
247
+ test('creates the tables the first time and finds them the second', async () => {
248
+ const db = memoryDb()
249
+ try {
250
+ assert.equal(await ensurePikkuSchema(db, workflowSchema), 'created')
251
+ assert.equal(await ensurePikkuSchema(db, workflowSchema), 'present')
252
+ } finally {
253
+ await db.destroy()
254
+ }
255
+ })
256
+
257
+ test('two services declaring the same schema do not fight over it', async () => {
258
+ // The workflow service and the workflow mirror both own these tables, and
259
+ // both call init(). Whoever runs second must find them and stop, rather
260
+ // than issuing DDL the database will reject.
261
+ const db = memoryDb()
262
+ try {
263
+ assert.equal(await ensurePikkuSchema(db, workflowSchema), 'created')
264
+ assert.equal(await ensurePikkuSchema(db, workflowSchema), 'present')
265
+ } finally {
266
+ await db.destroy()
267
+ }
268
+ })
269
+
270
+ test('refuses a half-applied schema rather than filling in the rest', async () => {
271
+ // Something else already owns part of it — a migration, an older release,
272
+ // a hand-run script. Creating the remainder at boot leaves two authorities
273
+ // over one set of tables, which is the condition all of this exists to end.
274
+ const db = memoryDb()
275
+ try {
276
+ await db.schema
277
+ .createTable('workflow_runs')
278
+ .addColumn('workflow_run_id', 'text', (col) => col.primaryKey())
279
+ .execute()
280
+
281
+ await assert.rejects(
282
+ ensurePikkuSchema(db, workflowSchema),
283
+ /The 'workflow' schema is half applied/
284
+ )
285
+ } finally {
286
+ await db.destroy()
287
+ }
288
+ })
289
+ })
290
+
291
+ describe('ensurePikkuSchema — a connection bound to a schema', () => {
292
+ const WORKFLOW_TABLES = [
293
+ 'workflow_runs',
294
+ 'workflow_step',
295
+ 'workflow_step_history',
296
+ 'workflow_versions',
297
+ ]
298
+
299
+ /**
300
+ * A postgres connection whose introspection answers with `tables`.
301
+ *
302
+ * Stubbed rather than run against a real database because sqlite has one
303
+ * schema and nothing else here speaks postgres — and introspection is the
304
+ * only thing `ensurePikkuSchema` reads before it decides. DummyDriver takes
305
+ * whatever DDL follows.
306
+ */
307
+ const introspecting = (
308
+ tables: Array<{ schema: string | undefined; name: string }>
309
+ ) =>
310
+ new Kysely<any>({
311
+ dialect: {
312
+ ...dialect('postgres'),
313
+ createIntrospector: () => ({
314
+ getSchemas: async () => [],
315
+ getMetadata: async () => ({ tables: [] }),
316
+ getTables: async () =>
317
+ tables.map(({ schema, name }) => ({
318
+ schema,
319
+ name,
320
+ isView: false,
321
+ columns: [],
322
+ })),
323
+ }),
324
+ },
325
+ })
326
+
327
+ test('reads the schema out of its own DDL, not the table name', () => {
328
+ const db = new Kysely<any>({ dialect: dialect('postgres') })
329
+
330
+ assert.deepEqual(declaredTables(workflowSchema, db.withSchema('app')), [
331
+ { schema: 'app', name: 'workflow_runs' },
332
+ { schema: 'app', name: 'workflow_step' },
333
+ { schema: 'app', name: 'workflow_step_history' },
334
+ { schema: 'app', name: 'workflow_versions' },
335
+ ])
336
+
337
+ // Unqualified DDL resolves against the connection's search_path, which is
338
+ // not knowable from here — so there is no schema to report.
339
+ assert.deepEqual(
340
+ declaredTables(workflowSchema, db),
341
+ WORKFLOW_TABLES.map((name) => ({ schema: undefined, name }))
342
+ )
343
+ })
344
+
345
+ test('finds tables that already exist under that schema', async () => {
346
+ // The regression this exists for: a `withSchema('app')` connection reading
347
+ // its own `create table "app"."workflow_runs"` as a table called `app`,
348
+ // concluding every table was missing, and issuing a bare CREATE that the
349
+ // database rejects with `relation "workflow_runs" already exists` — on
350
+ // every boot, forever.
351
+ const db = introspecting(
352
+ WORKFLOW_TABLES.map((name) => ({ schema: 'app', name }))
353
+ )
354
+
355
+ assert.equal(
356
+ await ensurePikkuSchema(db.withSchema('app'), workflowSchema),
357
+ 'present'
358
+ )
359
+ })
360
+
361
+ test('does not accept the same names in a different schema', async () => {
362
+ // `withSchema('app')` reads and writes `app.workflow_runs`. A
363
+ // `public.workflow_runs` is a different table and answers nothing.
364
+ const db = introspecting(
365
+ WORKFLOW_TABLES.map((name) => ({ schema: 'public', name }))
366
+ )
367
+
368
+ assert.equal(
369
+ await ensurePikkuSchema(db.withSchema('app'), workflowSchema),
370
+ 'created'
371
+ )
372
+ })
373
+
374
+ test('still matches on the bare name when the connection is unbound', async () => {
375
+ const db = introspecting(
376
+ WORKFLOW_TABLES.map((name) => ({ schema: 'public', name }))
377
+ )
378
+
379
+ assert.equal(await ensurePikkuSchema(db, workflowSchema), 'present')
380
+ })
381
+
382
+ test('matches on the bare name when the engine reports no schemas', async () => {
383
+ // sqlite and mysql leave `TableMetadata.schema` unset. Holding out for the
384
+ // qualified pair there would read every existing table as missing — the
385
+ // same false negative this suite exists for, arrived at from the other side.
386
+ const db = introspecting(
387
+ WORKFLOW_TABLES.map((name) => ({ schema: undefined, name }))
388
+ )
389
+
390
+ assert.equal(
391
+ await ensurePikkuSchema(db.withSchema('main'), workflowSchema),
392
+ 'present'
393
+ )
394
+ })
395
+
396
+ test('leaves a syntax error that is not sqlite refusing a qualifier alone', async () => {
397
+ // Postgres compiles the same qualified reference legitimately, and can
398
+ // raise a syntax error of its own on this DDL — a column type resolved from
399
+ // `requires` goes in verbatim. Answering for it in sqlite's name would be
400
+ // the misleading-error bug, reintroduced.
401
+ const pgError = Object.assign(
402
+ new Error('syntax error at or near "not"'),
403
+ { code: '42601' }
404
+ )
405
+ const db = new Kysely<any>({
406
+ dialect: {
407
+ ...dialect('postgres'),
408
+ createDriver: () => new ThrowingDriver(pgError),
409
+ createIntrospector: () => ({
410
+ getSchemas: async () => [],
411
+ getMetadata: async () => ({ tables: [] }),
412
+ getTables: async () => [],
413
+ }),
414
+ },
415
+ })
416
+
417
+ await assert.rejects(
418
+ ensurePikkuSchema(db.withSchema('app'), workflowSchema),
419
+ (error: Error) => {
420
+ assert.equal(error, pgError, 'the engine error must pass through as-is')
421
+ return true
422
+ }
423
+ )
424
+ })
425
+
426
+ test('explains the qualified foreign key sqlite cannot express', async () => {
427
+ // `withSchema` qualifies foreign key targets too. Postgres needs that;
428
+ // sqlite refuses it, and says only `near ".": syntax error` — which names
429
+ // neither the schema nor the connection that produced it.
430
+ const db = new Kysely<any>({
431
+ dialect: new SqliteDialect({ database: new Database(':memory:') }),
432
+ })
433
+ try {
434
+ await assert.rejects(
435
+ ensurePikkuSchema(db.withSchema('main'), workflowSchema),
436
+ (error: Error) => {
437
+ assert.match(error.message, /bound to a schema/)
438
+ assert.match(error.message, /takes a bare table name/i)
439
+ assert.ok(error.cause, 'the engine error must survive as the cause')
440
+ return true
441
+ }
442
+ )
443
+ } finally {
444
+ await db.destroy()
445
+ }
446
+ })
447
+
448
+ test('names the schema when it reports a half-applied one', async () => {
449
+ const db = introspecting([{ schema: 'app', name: 'workflow_runs' }])
450
+
451
+ await assert.rejects(
452
+ ensurePikkuSchema(db.withSchema('app'), workflowSchema),
453
+ /app\.workflow_step.*missing.*app\.workflow_runs already there/s
454
+ )
455
+ })
456
+ })
@@ -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
+ }