@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.
- package/CHANGELOG.md +52 -0
- package/dist/src/index.d.ts +1 -0
- package/dist/src/index.js +1 -0
- package/dist/src/kysely-ai-run-state-service.js +3 -18
- package/dist/src/kysely-ai-storage-service.d.ts +0 -1
- package/dist/src/kysely-ai-storage-service.js +4 -101
- package/dist/src/kysely-channel-store.js +4 -24
- package/dist/src/kysely-credential-service.js +3 -24
- package/dist/src/kysely-deployment-service.d.ts +0 -1
- package/dist/src/kysely-deployment-service.js +4 -48
- package/dist/src/kysely-scope-service.js +4 -54
- package/dist/src/kysely-secret-service.js +3 -21
- package/dist/src/kysely-session-store.js +4 -11
- package/dist/src/kysely-tables.d.ts +2 -0
- package/dist/src/kysely-webhook-service.d.ts +0 -1
- package/dist/src/kysely-webhook-service.js +4 -51
- package/dist/src/kysely-workflow-mirror.d.ts +7 -3
- package/dist/src/kysely-workflow-mirror.js +10 -87
- package/dist/src/kysely-workflow-service.js +9 -93
- package/dist/src/schema/ai.schema.d.ts +12 -0
- package/dist/src/schema/ai.schema.js +89 -0
- package/dist/src/schema/channel.schema.d.ts +3 -0
- package/dist/src/schema/channel.schema.js +24 -0
- package/dist/src/schema/credential.schema.d.ts +11 -0
- package/dist/src/schema/credential.schema.js +33 -0
- package/dist/src/schema/deployment.schema.d.ts +3 -0
- package/dist/src/schema/deployment.schema.js +32 -0
- package/dist/src/schema/index.d.ts +99 -0
- package/dist/src/schema/index.js +274 -0
- package/dist/src/schema/pikku-schema.types.d.ts +75 -0
- package/dist/src/schema/pikku-schema.types.js +21 -0
- package/dist/src/schema/scope.schema.d.ts +18 -0
- package/dist/src/schema/scope.schema.js +61 -0
- package/dist/src/schema/secret.schema.d.ts +11 -0
- package/dist/src/schema/secret.schema.js +29 -0
- package/dist/src/schema/session.schema.d.ts +3 -0
- package/dist/src/schema/session.schema.js +13 -0
- package/dist/src/schema/webhook.schema.d.ts +3 -0
- package/dist/src/schema/webhook.schema.js +38 -0
- package/dist/src/schema/workflow.schema.d.ts +15 -0
- package/dist/src/schema/workflow.schema.js +86 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/index.ts +18 -0
- package/src/kysely-ai-run-state-service.ts +7 -28
- package/src/kysely-ai-storage-service.ts +4 -152
- package/src/kysely-channel-store.ts +4 -34
- package/src/kysely-credential-service.ts +3 -36
- package/src/kysely-deployment-service.ts +4 -63
- package/src/kysely-scope-service.ts +4 -81
- package/src/kysely-secret-service.ts +6 -33
- package/src/kysely-session-store.ts +4 -18
- package/src/kysely-tables.test.ts +110 -0
- package/src/kysely-tables.ts +2 -0
- package/src/kysely-webhook-service.ts +4 -67
- package/src/kysely-workflow-mirror.test.ts +7 -7
- package/src/kysely-workflow-mirror.ts +10 -109
- package/src/kysely-workflow-service.ts +12 -123
- package/src/schema/ai.schema.ts +140 -0
- package/src/schema/channel.schema.ts +37 -0
- package/src/schema/credential.schema.ts +46 -0
- package/src/schema/deployment.schema.ts +47 -0
- package/src/schema/index.ts +374 -0
- package/src/schema/pikku-schema.types.ts +94 -0
- package/src/schema/schema.test.ts +456 -0
- package/src/schema/scope.schema.ts +95 -0
- package/src/schema/secret.schema.ts +40 -0
- package/src/schema/session.schema.ts +20 -0
- package/src/schema/webhook.schema.ts +57 -0
- 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
|
+
}
|