@pikku/kysely 0.13.7 → 0.13.11
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 +202 -0
- package/dist/src/index.d.ts +4 -1
- package/dist/src/index.js +3 -1
- package/dist/src/kysely-audit-service.d.ts +34 -2
- package/dist/src/kysely-audit-service.js +163 -3
- package/dist/src/kysely-secret-service.d.ts +4 -3
- package/dist/src/kysely-secret-service.js +6 -3
- package/dist/src/kysely-tables.d.ts +31 -0
- package/dist/src/kysely-virtual-user-run-store.d.ts +43 -0
- package/dist/src/kysely-virtual-user-run-store.js +131 -0
- package/dist/src/kysely-workflow-service.d.ts +1 -0
- package/dist/src/kysely-workflow-service.js +22 -0
- package/dist/src/schema/audit.schema.d.ts +16 -0
- package/dist/src/schema/audit.schema.js +47 -0
- package/dist/src/schema/credential.schema.d.ts +3 -2
- package/dist/src/schema/credential.schema.js +4 -3
- package/dist/src/schema/index.d.ts +17 -4
- package/dist/src/schema/index.js +40 -6
- package/dist/src/schema/pikku-schema.types.d.ts +41 -5
- package/dist/src/schema/pikku-schema.types.js +27 -12
- package/dist/src/schema/virtual-user.schema.d.ts +19 -0
- package/dist/src/schema/virtual-user.schema.js +54 -0
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +2 -2
- package/src/create-audited-kysely.test.ts +10 -10
- package/src/index.ts +7 -0
- package/src/kysely-audit-service.test.ts +360 -0
- package/src/kysely-audit-service.ts +181 -4
- package/src/kysely-secret-service.ts +11 -10
- package/src/kysely-services.test.ts +3 -3
- package/src/kysely-tables.test.ts +12 -1
- package/src/kysely-tables.ts +32 -0
- package/src/kysely-virtual-user-run-store.test.ts +212 -0
- package/src/kysely-virtual-user-run-store.ts +149 -0
- package/src/kysely-workflow-service.ts +39 -0
- package/src/schema/audit.schema.ts +55 -0
- package/src/schema/credential.schema.ts +5 -3
- package/src/schema/index.ts +59 -10
- package/src/schema/pikku-schema.types.ts +65 -10
- package/src/schema/schema.test.ts +82 -4
- package/src/schema/virtual-user.schema.ts +65 -0
package/src/schema/index.ts
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import { CamelCasePlugin, type Kysely } from 'kysely'
|
|
2
|
-
import
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
2
|
+
import {
|
|
3
|
+
schemaContext,
|
|
4
|
+
type PikkuSchema,
|
|
5
|
+
type RequiredTypes,
|
|
6
|
+
type SchemaContext,
|
|
7
|
+
type SchemaRequirement,
|
|
6
8
|
} from './pikku-schema.types.js'
|
|
7
9
|
import { aiSchema } from './ai.schema.js'
|
|
8
10
|
import { channelSchema } from './channel.schema.js'
|
|
@@ -17,11 +19,24 @@ import { workflowSchema } from './workflow.schema.js'
|
|
|
17
19
|
export type {
|
|
18
20
|
PikkuSchema,
|
|
19
21
|
RequiredTypes,
|
|
22
|
+
SchemaContext,
|
|
20
23
|
SchemaRequirement,
|
|
21
24
|
SchemaStatement,
|
|
22
25
|
SchemaStatementFactory,
|
|
23
26
|
} from './pikku-schema.types.js'
|
|
24
|
-
export {
|
|
27
|
+
export {
|
|
28
|
+
rawStatement,
|
|
29
|
+
requiredType,
|
|
30
|
+
schemaContext,
|
|
31
|
+
unqualifiedContext,
|
|
32
|
+
} from './pikku-schema.types.js'
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Opt-in, so it is named here rather than folded into `pikkuSchemas` — the
|
|
36
|
+
* runtime works without an audit table, and `KyselyAuditService.init()` applies
|
|
37
|
+
* this for the projects that wire the sink.
|
|
38
|
+
*/
|
|
39
|
+
export { auditSchema } from './audit.schema.js'
|
|
25
40
|
|
|
26
41
|
/**
|
|
27
42
|
* Every table the pikku runtime needs, in dependency order.
|
|
@@ -53,6 +68,29 @@ export const pikkuSchemas: PikkuSchema[] = [
|
|
|
53
68
|
*/
|
|
54
69
|
const bind = (db: Kysely<any>) => db.withPlugin(new CamelCasePlugin())
|
|
55
70
|
|
|
71
|
+
/**
|
|
72
|
+
* The schema a connection is bound to, or `undefined` for one that is not.
|
|
73
|
+
*
|
|
74
|
+
* Read back out of compiled DDL rather than off the connection, because kysely
|
|
75
|
+
* keeps the `withSchema(...)` setting inside its executor and exposes no way to
|
|
76
|
+
* ask. Compiling a throwaway `create table` is the same trick `declaredTables`
|
|
77
|
+
* uses on the real statements, against the same regex — nothing is executed and
|
|
78
|
+
* the probe never leaves this function.
|
|
79
|
+
*
|
|
80
|
+
* Derived rather than passed in so that raw statements follow whatever
|
|
81
|
+
* connection they are handed. A caller that had to remember to say the schema
|
|
82
|
+
* twice would eventually say it once.
|
|
83
|
+
*/
|
|
84
|
+
const boundSchema = (db: Kysely<any>): string | undefined =>
|
|
85
|
+
CREATE_TABLE.exec(
|
|
86
|
+
db.schema.createTable('pikkuSchemaProbe').addColumn('id', 'text').compile()
|
|
87
|
+
.sql
|
|
88
|
+
)?.[1]
|
|
89
|
+
|
|
90
|
+
/** The context for statements compiled against `db`, matching its binding. */
|
|
91
|
+
const contextFor = (db: Kysely<any>): SchemaContext =>
|
|
92
|
+
schemaContext(boundSchema(db))
|
|
93
|
+
|
|
56
94
|
export interface UnmetRequirement {
|
|
57
95
|
schema: PikkuSchema
|
|
58
96
|
requirement: SchemaRequirement
|
|
@@ -141,9 +179,10 @@ export const applyPikkuSchemas = async (
|
|
|
141
179
|
|
|
142
180
|
await db.transaction().execute(async (trx) => {
|
|
143
181
|
const bound = bind(trx)
|
|
182
|
+
const ctx = contextFor(bound)
|
|
144
183
|
for (const schema of schemas) {
|
|
145
184
|
for (const statement of schema.statements) {
|
|
146
|
-
const query = statement(bound, types)
|
|
185
|
+
const query = statement(bound, types, ctx)
|
|
147
186
|
try {
|
|
148
187
|
await query.execute()
|
|
149
188
|
} catch (error) {
|
|
@@ -239,9 +278,10 @@ export const declaredTables = (
|
|
|
239
278
|
db: Kysely<any>
|
|
240
279
|
): DeclaredTable[] => {
|
|
241
280
|
const bound = bind(db)
|
|
281
|
+
const ctx = contextFor(bound)
|
|
242
282
|
const tables: DeclaredTable[] = []
|
|
243
283
|
for (const statement of schema.statements) {
|
|
244
|
-
const match = CREATE_TABLE.exec(statement(bound, {}).compile().sql)
|
|
284
|
+
const match = CREATE_TABLE.exec(statement(bound, {}, ctx).compile().sql)
|
|
245
285
|
if (match) tables.push({ schema: match[1], name: match[2]! })
|
|
246
286
|
}
|
|
247
287
|
return tables
|
|
@@ -357,17 +397,26 @@ export const ensurePikkuSchema = async (
|
|
|
357
397
|
* `types` comes from `resolveRequirements` against the database the migration
|
|
358
398
|
* is being written for. Omit it and foreign keys onto another source's columns
|
|
359
399
|
* compile as `text`, which is a shape, not a migration.
|
|
400
|
+
*
|
|
401
|
+
* `schema` qualifies every table the migration creates, for a project that
|
|
402
|
+
* keeps the runtime tables somewhere other than the default search path. It is
|
|
403
|
+
* applied here and not to `db` itself because the caller's connection is a
|
|
404
|
+
* throwaway the declaration was just *applied* to, in whatever schema that
|
|
405
|
+
* database actually has — qualifying that would create tables in a schema the
|
|
406
|
+
* scratch database has never heard of. Only the rendered SQL is bound.
|
|
360
407
|
*/
|
|
361
408
|
export const compilePikkuSchemas = (
|
|
362
409
|
db: Kysely<any>,
|
|
363
410
|
schemas: PikkuSchema[] = pikkuSchemas,
|
|
364
|
-
types: RequiredTypes = {}
|
|
411
|
+
types: RequiredTypes = {},
|
|
412
|
+
schemaName?: string
|
|
365
413
|
): string => {
|
|
366
|
-
const bound = bind(db)
|
|
414
|
+
const bound = schemaName ? bind(db).withSchema(schemaName) : bind(db)
|
|
415
|
+
const ctx = contextFor(bound)
|
|
367
416
|
return schemas
|
|
368
417
|
.flatMap((schema) =>
|
|
369
418
|
schema.statements.map(
|
|
370
|
-
(statement) => `${statement(bound, types).compile().sql};`
|
|
419
|
+
(statement) => `${statement(bound, types, ctx).compile().sql};`
|
|
371
420
|
)
|
|
372
421
|
)
|
|
373
422
|
.join('\n\n')
|
|
@@ -15,9 +15,51 @@ export interface SchemaStatement {
|
|
|
15
15
|
|
|
16
16
|
export type SchemaStatementFactory = (
|
|
17
17
|
db: Kysely<any>,
|
|
18
|
-
types: RequiredTypes
|
|
18
|
+
types: RequiredTypes,
|
|
19
|
+
ctx: SchemaContext
|
|
19
20
|
) => SchemaStatement
|
|
20
21
|
|
|
22
|
+
/**
|
|
23
|
+
* What a statement needs to know about the schema its DDL lands in.
|
|
24
|
+
*
|
|
25
|
+
* Only raw SQL needs this. The schema builder learns the schema from the
|
|
26
|
+
* connection — `withSchema('app')` qualifies every table it names, foreign key
|
|
27
|
+
* targets included — but a raw fragment is passed through verbatim, so a
|
|
28
|
+
* statement that spells its own SQL has to spell the qualifier too.
|
|
29
|
+
*/
|
|
30
|
+
export interface SchemaContext {
|
|
31
|
+
/**
|
|
32
|
+
* A table as the surrounding DDL addresses it: `"app"."credentials"` on a
|
|
33
|
+
* schema-bound connection, `"credentials"` otherwise.
|
|
34
|
+
*
|
|
35
|
+
* Takes the physical name, not the camelCase one the schema builder accepts.
|
|
36
|
+
* Raw SQL does not go through `CamelCasePlugin`, so it is already written in
|
|
37
|
+
* physical names throughout and this is no exception.
|
|
38
|
+
*/
|
|
39
|
+
table: (name: string) => RawBuilder<unknown>
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The `SchemaContext` for DDL that names no schema.
|
|
44
|
+
*
|
|
45
|
+
* The default everywhere a schema has not been asked for, which is every engine
|
|
46
|
+
* but postgres and most postgres projects too.
|
|
47
|
+
*/
|
|
48
|
+
export const unqualifiedContext: SchemaContext = {
|
|
49
|
+
table: (name) => sql.raw(`"${name}"`),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* A `SchemaContext` qualifying every table into `schema`.
|
|
54
|
+
*
|
|
55
|
+
* Identifiers are quoted rather than interpolated bare so a schema named after
|
|
56
|
+
* a reserved word still compiles.
|
|
57
|
+
*/
|
|
58
|
+
export const schemaContext = (schema: string | undefined): SchemaContext =>
|
|
59
|
+
schema
|
|
60
|
+
? { table: (name) => sql.raw(`"${schema}"."${name}"`) }
|
|
61
|
+
: unqualifiedContext
|
|
62
|
+
|
|
21
63
|
/**
|
|
22
64
|
* The physical types of the columns named in `requires`, keyed `table.column`.
|
|
23
65
|
*
|
|
@@ -82,13 +124,26 @@ export interface SchemaRequirement {
|
|
|
82
124
|
* Wrap a raw SQL fragment as a statement.
|
|
83
125
|
*
|
|
84
126
|
* For the DDL kysely's schema builder cannot express — a unique index over an
|
|
85
|
-
* expression, say.
|
|
86
|
-
*
|
|
87
|
-
*
|
|
127
|
+
* expression, say. Raw SQL is *not* rewritten by `withSchema`, so a fragment
|
|
128
|
+
* naming a table unqualified resolves against the connection's search path
|
|
129
|
+
* rather than the schema the rest of the declaration lands in. Take the
|
|
130
|
+
* function form and name tables through `ctx.table` to stay with them; the
|
|
131
|
+
* bare-fragment form is for SQL that names no table at all.
|
|
88
132
|
*/
|
|
89
|
-
export
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
133
|
+
export function rawStatement(
|
|
134
|
+
fragment: RawBuilder<unknown>
|
|
135
|
+
): SchemaStatementFactory
|
|
136
|
+
export function rawStatement(
|
|
137
|
+
build: (ctx: SchemaContext) => RawBuilder<unknown>
|
|
138
|
+
): SchemaStatementFactory
|
|
139
|
+
export function rawStatement(
|
|
140
|
+
source: RawBuilder<unknown> | ((ctx: SchemaContext) => RawBuilder<unknown>)
|
|
141
|
+
): SchemaStatementFactory {
|
|
142
|
+
return (db, _types, ctx = unqualifiedContext) => {
|
|
143
|
+
const fragment = typeof source === 'function' ? source(ctx) : source
|
|
144
|
+
return {
|
|
145
|
+
execute: () => fragment.execute(db),
|
|
146
|
+
compile: () => fragment.compile(db),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -398,10 +398,9 @@ describe('ensurePikkuSchema — a connection bound to a schema', () => {
|
|
|
398
398
|
// raise a syntax error of its own on this DDL — a column type resolved from
|
|
399
399
|
// `requires` goes in verbatim. Answering for it in sqlite's name would be
|
|
400
400
|
// the misleading-error bug, reintroduced.
|
|
401
|
-
const pgError = Object.assign(
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
)
|
|
401
|
+
const pgError = Object.assign(new Error('syntax error at or near "not"'), {
|
|
402
|
+
code: '42601',
|
|
403
|
+
})
|
|
405
404
|
const db = new Kysely<any>({
|
|
406
405
|
dialect: {
|
|
407
406
|
...dialect('postgres'),
|
|
@@ -454,3 +453,82 @@ describe('ensurePikkuSchema — a connection bound to a schema', () => {
|
|
|
454
453
|
)
|
|
455
454
|
})
|
|
456
455
|
})
|
|
456
|
+
|
|
457
|
+
describe('compiling into a schema', () => {
|
|
458
|
+
const compilePg = (schemaName?: string) =>
|
|
459
|
+
compilePikkuSchemas(
|
|
460
|
+
new Kysely<any>({ dialect: dialect('postgres') }),
|
|
461
|
+
pikkuSchemas,
|
|
462
|
+
{},
|
|
463
|
+
schemaName
|
|
464
|
+
)
|
|
465
|
+
|
|
466
|
+
test('qualifies every table it creates', () => {
|
|
467
|
+
const sql = compilePg('app')
|
|
468
|
+
const creates = sql.match(/create table[^(]*/gi) ?? []
|
|
469
|
+
|
|
470
|
+
assert.ok(creates.length > 0, 'nothing was compiled')
|
|
471
|
+
for (const create of creates) {
|
|
472
|
+
assert.match(
|
|
473
|
+
create,
|
|
474
|
+
/"app"\./,
|
|
475
|
+
`unqualified create table in a schema-bound compile: ${create.trim()}`
|
|
476
|
+
)
|
|
477
|
+
}
|
|
478
|
+
})
|
|
479
|
+
|
|
480
|
+
test('qualifies foreign key targets, so they do not resolve by search path', () => {
|
|
481
|
+
const sql = compilePg('app')
|
|
482
|
+
|
|
483
|
+
assert.match(sql, /references "app"\./)
|
|
484
|
+
assert.doesNotMatch(
|
|
485
|
+
sql,
|
|
486
|
+
/references "(?!app")[^"]+" \(/,
|
|
487
|
+
'a foreign key onto an unqualified table would find whatever search_path points at'
|
|
488
|
+
)
|
|
489
|
+
})
|
|
490
|
+
|
|
491
|
+
/**
|
|
492
|
+
* The regression this option exists for.
|
|
493
|
+
*
|
|
494
|
+
* Raw SQL is passed through verbatim, so the expression index on `credentials`
|
|
495
|
+
* is the one statement `withSchema` cannot reach. Left unqualified it creates
|
|
496
|
+
* the uniqueness rule on a different table than the `create table` above it.
|
|
497
|
+
*/
|
|
498
|
+
test('qualifies the raw expression index too', () => {
|
|
499
|
+
assert.match(
|
|
500
|
+
compilePg('app'),
|
|
501
|
+
/CREATE UNIQUE INDEX credentials_name_user_id_unique ON "app"\."credentials"/
|
|
502
|
+
)
|
|
503
|
+
})
|
|
504
|
+
|
|
505
|
+
test('leaves the index name itself unqualified', () => {
|
|
506
|
+
// An index belongs to the schema of the table it is on; postgres rejects a
|
|
507
|
+
// CREATE INDEX that says otherwise.
|
|
508
|
+
assert.doesNotMatch(compilePg('app'), /INDEX "?app"?\."?credentials_name/i)
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
test('compiles unqualified when no schema is asked for', () => {
|
|
512
|
+
const sql = compilePg()
|
|
513
|
+
|
|
514
|
+
assert.doesNotMatch(sql, /"app"\./)
|
|
515
|
+
assert.match(sql, /create table "credentials"/i)
|
|
516
|
+
assert.match(
|
|
517
|
+
sql,
|
|
518
|
+
/CREATE UNIQUE INDEX credentials_name_user_id_unique ON "credentials"/
|
|
519
|
+
)
|
|
520
|
+
})
|
|
521
|
+
|
|
522
|
+
test('does not qualify the connection it was handed', () => {
|
|
523
|
+
// The caller's connection is a scratch database the declaration was just
|
|
524
|
+
// applied to. Binding it would create tables in a schema that database has
|
|
525
|
+
// never heard of — so the schema must reach the SQL and nothing else.
|
|
526
|
+
const db = new Kysely<any>({ dialect: dialect('postgres') })
|
|
527
|
+
compilePikkuSchemas(db, pikkuSchemas, {}, 'app')
|
|
528
|
+
|
|
529
|
+
assert.match(
|
|
530
|
+
db.schema.createTable('after').addColumn('id', 'text').compile().sql,
|
|
531
|
+
/create table "after"/
|
|
532
|
+
)
|
|
533
|
+
})
|
|
534
|
+
})
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { sql } from 'kysely'
|
|
2
|
+
import type { PikkuSchema } from './pikku-schema.types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* The `virtualUserRun` table {@link KyselyVirtualUserRunStore} writes to.
|
|
6
|
+
*
|
|
7
|
+
* Deliberately not in `pikkuSchemas`: the runtime never needs this table, only
|
|
8
|
+
* a project that runs virtual users does, and creating it for everyone would
|
|
9
|
+
* put an empty table in every database. `KyselyVirtualUserRunStore.init()`
|
|
10
|
+
* applies it, so it arrives exactly when the thing that fills it does.
|
|
11
|
+
*
|
|
12
|
+
* `persona` is text rather than a foreign key: personas are declared in code
|
|
13
|
+
* with `definePersonas()`, not rows, and a run has to outlive the deletion of
|
|
14
|
+
* the persona it ran as.
|
|
15
|
+
*
|
|
16
|
+
* The JSON columns are text on every engine, like the audit table's, because
|
|
17
|
+
* the store serialises them itself — a project on SQLite and one on Postgres
|
|
18
|
+
* then hold the same bytes, and comparing this week's findings against last
|
|
19
|
+
* week's does not depend on which database they were found in.
|
|
20
|
+
*/
|
|
21
|
+
export const virtualUserSchema: PikkuSchema = {
|
|
22
|
+
name: 'virtualUser',
|
|
23
|
+
statements: [
|
|
24
|
+
(db) =>
|
|
25
|
+
db.schema
|
|
26
|
+
.createTable('virtualUserRun')
|
|
27
|
+
.addColumn('runId', 'varchar(36)', (col) => col.primaryKey())
|
|
28
|
+
.addColumn('persona', 'varchar(255)', (col) => col.notNull())
|
|
29
|
+
.addColumn('disposition', 'varchar(50)', (col) => col.notNull())
|
|
30
|
+
.addColumn('seed', 'bigint', (col) => col.notNull())
|
|
31
|
+
// running | completed | failed. Not derived from `finishedAt` being
|
|
32
|
+
// null: a crashed run has no finish time either, and "crashed" and
|
|
33
|
+
// "still going" are not the same answer.
|
|
34
|
+
.addColumn('status', 'varchar(50)', (col) =>
|
|
35
|
+
col.defaultTo('running').notNull()
|
|
36
|
+
)
|
|
37
|
+
.addColumn('goals', 'text', (col) => col.defaultTo('[]').notNull())
|
|
38
|
+
.addColumn('memory', 'text', (col) => col.defaultTo('{}').notNull())
|
|
39
|
+
.addColumn('findings', 'text', (col) => col.defaultTo('[]').notNull())
|
|
40
|
+
// Counts the engine kept: steps, calls, mutations, tokens, elapsed.
|
|
41
|
+
.addColumn('tally', 'text')
|
|
42
|
+
.addColumn('stoppedBy', 'varchar(255)')
|
|
43
|
+
// Why the run itself failed, as opposed to what it found.
|
|
44
|
+
.addColumn('error', 'text')
|
|
45
|
+
.addColumn('startedBy', 'varchar(255)')
|
|
46
|
+
.addColumn('createdAt', 'timestamp', (col) =>
|
|
47
|
+
col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
|
|
48
|
+
)
|
|
49
|
+
.addColumn('finishedAt', 'timestamp'),
|
|
50
|
+
|
|
51
|
+
// The two reads: the run list newest-first, and one persona's history when
|
|
52
|
+
// a finding needs comparing against what that persona found before.
|
|
53
|
+
(db) =>
|
|
54
|
+
db.schema
|
|
55
|
+
.createIndex('idx_virtual_user_run_created')
|
|
56
|
+
.on('virtualUserRun')
|
|
57
|
+
.column('createdAt'),
|
|
58
|
+
|
|
59
|
+
(db) =>
|
|
60
|
+
db.schema
|
|
61
|
+
.createIndex('idx_virtual_user_run_persona')
|
|
62
|
+
.on('virtualUserRun')
|
|
63
|
+
.columns(['persona', 'createdAt']),
|
|
64
|
+
],
|
|
65
|
+
}
|