@pikku/kysely 0.13.31 → 0.13.34

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 (38) hide show
  1. package/CHANGELOG.md +84 -0
  2. package/README.md +12 -2
  3. package/dist/src/index.d.ts +2 -1
  4. package/dist/src/index.js +2 -1
  5. package/dist/src/kysely-lease-clock.d.ts +14 -0
  6. package/dist/src/kysely-lease-clock.js +14 -0
  7. package/dist/src/kysely-lease-service.d.ts +37 -0
  8. package/dist/src/kysely-lease-service.js +116 -0
  9. package/dist/src/kysely-tables.d.ts +16 -0
  10. package/dist/src/kysely-workflow-service.d.ts +36 -20
  11. package/dist/src/kysely-workflow-service.js +195 -102
  12. package/dist/src/schema/agent.schema.js +43 -36
  13. package/dist/src/schema/analytics.schema.js +17 -17
  14. package/dist/src/schema/audit.schema.js +22 -22
  15. package/dist/src/schema/channel.schema.js +13 -10
  16. package/dist/src/schema/credential.schema.js +6 -6
  17. package/dist/src/schema/deployment.schema.js +12 -12
  18. package/dist/src/schema/flag.schema.js +15 -12
  19. package/dist/src/schema/incoming-webhook.schema.js +7 -7
  20. package/dist/src/schema/index.d.ts +1 -0
  21. package/dist/src/schema/index.js +33 -7
  22. package/dist/src/schema/lease.schema.d.ts +2 -0
  23. package/dist/src/schema/lease.schema.js +12 -0
  24. package/dist/src/schema/pikku-schema.types.d.ts +42 -2
  25. package/dist/src/schema/pikku-schema.types.js +23 -7
  26. package/dist/src/schema/scope.schema.js +34 -22
  27. package/dist/src/schema/secret.schema.js +5 -5
  28. package/dist/src/schema/session.schema.js +3 -3
  29. package/dist/src/schema/trigger-source.schema.js +8 -8
  30. package/dist/src/schema/virtual-user-schedule.schema.js +4 -4
  31. package/dist/src/schema/virtual-user.schema.js +14 -14
  32. package/dist/src/schema/webhook.schema.js +17 -17
  33. package/dist/src/schema/workflow.schema.js +50 -49
  34. package/knowledge/decisions/a-lease-is-taken-by-one-guarded-update.md +30 -0
  35. package/knowledge/decisions/index.md +3 -0
  36. package/knowledge/decisions/leases-are-judged-by-the-database-clock.md +42 -0
  37. package/knowledge/decisions/workflows-own-the-lease-table-too.md +22 -0
  38. package/package.json +2 -2
@@ -17,33 +17,33 @@ export const auditSchema = {
17
17
  name: 'audit',
18
18
  ownedBy: ['audit', 'auditLog'],
19
19
  statements: [
20
- (db) => db.schema
20
+ (db, _types, ctx) => db.schema
21
21
  .createTable('audit')
22
- .addColumn('auditId', 'text', (col) => col.primaryKey())
23
- .addColumn('occurredAt', 'text', (col) => col.notNull())
24
- .addColumn('type', 'text', (col) => col.notNull())
25
- .addColumn('source', 'text', (col) => col.defaultTo('auto').notNull())
26
- .addColumn('outcome', 'text')
27
- .addColumn('functionId', 'text')
28
- .addColumn('wireType', 'text')
29
- .addColumn('traceId', 'text')
30
- .addColumn('transactionId', 'text')
31
- .addColumn('queryId', 'text')
32
- .addColumn('userId', 'text')
33
- .addColumn('orgId', 'text')
34
- .addColumn('pikkuUserId', 'text')
35
- .addColumn('tables', 'text')
36
- .addColumn('changedCols', 'text')
37
- .addColumn('event', 'text')
38
- .addColumn('old', 'text')
39
- .addColumn('data', 'text'),
22
+ .addColumn('auditId', ctx.key, (col) => col.primaryKey())
23
+ .addColumn('occurredAt', ctx.key, (col) => col.notNull())
24
+ .addColumn('type', ctx.key, (col) => col.notNull())
25
+ .addColumn('source', ctx.text, (col) => col.defaultTo(ctx.defaultText('auto')).notNull())
26
+ .addColumn('outcome', ctx.text)
27
+ .addColumn('functionId', ctx.text)
28
+ .addColumn('wireType', ctx.text)
29
+ .addColumn('traceId', ctx.text)
30
+ .addColumn('transactionId', ctx.text)
31
+ .addColumn('queryId', ctx.text)
32
+ .addColumn('userId', ctx.key)
33
+ .addColumn('orgId', ctx.text)
34
+ .addColumn('pikkuUserId', ctx.text)
35
+ .addColumn('tables', ctx.text)
36
+ .addColumn('changedCols', ctx.text)
37
+ .addColumn('event', ctx.text)
38
+ .addColumn('old', ctx.text)
39
+ .addColumn('data', ctx.text),
40
40
  // The trail is only ever read newest-first, and the two filters the console
41
41
  // offers are user and type.
42
- (db) => db.schema
42
+ (db, _types, ctx) => db.schema
43
43
  .createIndex('idx_audit_occurred_at')
44
44
  .on('audit')
45
45
  .column('occurredAt'),
46
- (db) => db.schema.createIndex('idx_audit_user_id').on('audit').column('userId'),
47
- (db) => db.schema.createIndex('idx_audit_type').on('audit').column('type'),
46
+ (db, _types, ctx) => db.schema.createIndex('idx_audit_user_id').on('audit').column('userId'),
47
+ (db, _types, ctx) => db.schema.createIndex('idx_audit_type').on('audit').column('type'),
48
48
  ],
49
49
  };
@@ -4,22 +4,25 @@ export const channelSchema = {
4
4
  name: 'channel',
5
5
  ownedBy: ['channelStore', 'eventHub'],
6
6
  statements: [
7
- (db) => db.schema
7
+ (db, _types, ctx) => db.schema
8
8
  .createTable('channels')
9
- .addColumn('channelId', 'text', (col) => col.primaryKey())
10
- .addColumn('channelName', 'text', (col) => col.notNull())
9
+ .addColumn('channelId', ctx.key, (col) => col.primaryKey())
10
+ .addColumn('channelName', ctx.text, (col) => col.notNull())
11
11
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
12
- .addColumn('openingData', 'text', (col) => col.notNull().defaultTo('{}'))
13
- .addColumn('pikkuUserId', 'text')
14
- .addColumn('state', 'text')
12
+ .addColumn('openingData', ctx.text, (col) => col.notNull().defaultTo(ctx.defaultText('{}')))
13
+ .addColumn('pikkuUserId', ctx.text)
14
+ .addColumn('state', ctx.text)
15
15
  .addColumn('lastWire', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
16
- (db) => db.schema
16
+ (db, _types, ctx) => db.schema
17
17
  .createTable('channelSubscriptions')
18
- .addColumn('channelId', 'text', (col) => col.notNull().references('channels.channelId').onDelete('cascade'))
19
- .addColumn('topic', 'text', (col) => col.notNull())
18
+ .addColumn('channelId', ctx.key, (col) => ctx.references(col.notNull(), 'channels.channelId'))
19
+ .addColumn('topic', ctx.key, (col) => col.notNull())
20
20
  .addPrimaryKeyConstraint('channel_subscriptions_pk', [
21
21
  'channelId',
22
22
  'topic',
23
- ]),
23
+ ])
24
+ .$call(ctx.foreignKeys('channelSubscriptions', {
25
+ channelId: 'channels.channelId',
26
+ })),
24
27
  ],
25
28
  };
@@ -14,22 +14,22 @@ export const credentialSchema = {
14
14
  name: 'credential',
15
15
  ownedBy: ['credentialService'],
16
16
  statements: [
17
- (db) => db.schema
17
+ (db, _types, ctx) => db.schema
18
18
  .createTable('credentials')
19
19
  .addColumn('name', 'varchar(255)', (col) => col.notNull())
20
20
  .addColumn('userId', 'varchar(255)')
21
- .addColumn('ciphertext', 'text', (col) => col.notNull())
22
- .addColumn('wrappedDek', 'text', (col) => col.notNull())
21
+ .addColumn('ciphertext', ctx.text, (col) => col.notNull())
22
+ .addColumn('wrappedDek', ctx.text, (col) => col.notNull())
23
23
  .addColumn('keyVersion', 'integer', (col) => col.notNull())
24
24
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
25
25
  .addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
26
- rawStatement(({ table }) => sql `CREATE UNIQUE INDEX credentials_name_user_id_unique ON ${table('credentials')} (name, COALESCE(user_id, ''))`),
27
- (db) => db.schema
26
+ rawStatement(({ table, mysql }) => sql `CREATE UNIQUE INDEX credentials_name_user_id_unique ON ${table('credentials')} (name, ${mysql ? sql `(COALESCE(user_id, ''))` : sql `COALESCE(user_id, '')`})`),
27
+ (db, _types, ctx) => db.schema
28
28
  .createTable('credentialKekSalts')
29
29
  .addColumn('keyVersion', 'integer', (col) => col.primaryKey())
30
30
  .addColumn('salt', 'varchar(64)', (col) => col.notNull())
31
31
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
32
- (db) => db.schema
32
+ (db, _types, ctx) => db.schema
33
33
  .createTable('credentialsAudit')
34
34
  .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
35
35
  .addColumn('credentialName', 'varchar(255)', (col) => col.notNull())
@@ -3,28 +3,28 @@ import { sql } from 'kysely';
3
3
  export const deploymentSchema = {
4
4
  name: 'deployment',
5
5
  statements: [
6
- (db) => db.schema
6
+ (db, _types, ctx) => db.schema
7
7
  .createTable('pikkuDeployments')
8
- .addColumn('deploymentId', 'text', (col) => col.primaryKey())
9
- .addColumn('endpoint', 'text', (col) => col.notNull())
8
+ .addColumn('deploymentId', ctx.key, (col) => col.primaryKey())
9
+ .addColumn('endpoint', ctx.text, (col) => col.notNull())
10
10
  .addColumn('lastHeartbeat', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
11
11
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
12
- (db) => db.schema
12
+ (db, _types, ctx) => db.schema
13
13
  .createTable('pikkuDeploymentFunctions')
14
- .addColumn('deploymentId', 'text', (col) => col
15
- .notNull()
16
- .references('pikkuDeployments.deploymentId')
17
- .onDelete('cascade'))
18
- .addColumn('functionName', 'text', (col) => col.notNull())
14
+ .addColumn('deploymentId', ctx.key, (col) => ctx.references(col.notNull(), 'pikkuDeployments.deploymentId'))
15
+ .addColumn('functionName', ctx.key, (col) => col.notNull())
19
16
  .addPrimaryKeyConstraint('pikku_deployment_functions_pk', [
20
17
  'deploymentId',
21
18
  'functionName',
22
- ]),
23
- (db) => db.schema
19
+ ])
20
+ .$call(ctx.foreignKeys('pikkuDeploymentFunctions', {
21
+ deploymentId: 'pikkuDeployments.deploymentId',
22
+ })),
23
+ (db, _types, ctx) => db.schema
24
24
  .createIndex('idx_pikku_deployments_heartbeat')
25
25
  .on('pikkuDeployments')
26
26
  .column('lastHeartbeat'),
27
- (db) => db.schema
27
+ (db, _types, ctx) => db.schema
28
28
  .createIndex('idx_pikku_deployment_functions_name')
29
29
  .on('pikkuDeploymentFunctions')
30
30
  .column('functionName'),
@@ -17,14 +17,14 @@ export const flagSchema = {
17
17
  name: 'flag',
18
18
  ownedBy: ['featureFlags'],
19
19
  statements: [
20
- (db) => db.schema
20
+ (db, _types, ctx) => db.schema
21
21
  .createTable('pikkuFeatureFlags')
22
- .addColumn('name', 'text', (col) => col.primaryKey())
23
- .addColumn('description', 'text')
22
+ .addColumn('name', ctx.key, (col) => col.primaryKey())
23
+ .addColumn('description', ctx.text)
24
24
  // The declared `anyOf` scope list, as JSON. Read by the console to say
25
25
  // who would see the feature; never read on the request path, where the
26
26
  // capability half comes from the session's own scopes.
27
- .addColumn('anyOf', 'text')
27
+ .addColumn('anyOf', ctx.text)
28
28
  // Off until somebody turns it on. A flag exists so a feature can ship
29
29
  // dark, so the row a deploy creates has to start closed — the opposite
30
30
  // default would make merging the declaration the moment of launch.
@@ -37,26 +37,29 @@ export const flagSchema = {
37
37
  // longer offered, awaiting `pikku flags prune`. The same additive
38
38
  // contract as `pikkuScopes.declared`.
39
39
  .addColumn('declared', 'boolean', (col) => col.defaultTo(true).notNull())
40
- .addColumn('updatedBy', 'text')
40
+ .addColumn('updatedBy', ctx.text)
41
41
  // Why it was flipped. A kill switch is pulled during an incident, and
42
42
  // the one question afterwards is always who turned it off and why.
43
- .addColumn('note', 'text')
43
+ .addColumn('note', ctx.text)
44
44
  .addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
45
- (db) => db.schema
45
+ (db, _types, ctx) => db.schema
46
46
  .createTable('pikkuFeatureFlagOverrides')
47
- .addColumn('flag', 'text', (col) => col.notNull().references('pikkuFeatureFlags.name').onDelete('cascade'))
48
- .addColumn('subjectId', 'text', (col) => col.notNull())
47
+ .addColumn('flag', ctx.key, (col) => ctx.references(col.notNull(), 'pikkuFeatureFlags.name'))
48
+ .addColumn('subjectId', ctx.key, (col) => col.notNull())
49
49
  // Which id this is, for the console to render. Resolution keys on the
50
50
  // id alone, because that is all the resolver has: it prefers the
51
51
  // organization and falls back to the user, and by then the two are one
52
52
  // string.
53
- .addColumn('subjectKind', 'text', (col) => col.notNull())
53
+ .addColumn('subjectKind', ctx.text, (col) => col.notNull())
54
54
  .addColumn('enabled', 'boolean', (col) => col.notNull())
55
- .addColumn('grantedBy', 'text')
55
+ .addColumn('grantedBy', ctx.text)
56
56
  .addColumn('grantedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
57
57
  .addPrimaryKeyConstraint('pikku_feature_flag_overrides_pk', [
58
58
  'flag',
59
59
  'subjectId',
60
- ]),
60
+ ])
61
+ .$call(ctx.foreignKeys('pikkuFeatureFlagOverrides', {
62
+ flag: 'pikkuFeatureFlags.name',
63
+ })),
61
64
  ],
62
65
  };
@@ -4,15 +4,15 @@ export const incomingWebhookSchema = {
4
4
  name: 'incoming-webhook',
5
5
  ownedBy: ['incomingWebhookService'],
6
6
  statements: [
7
- (db) => db.schema
7
+ (db, _types, ctx) => db.schema
8
8
  .createTable('webhookReceipt')
9
- .addColumn('receiptId', 'text', (col) => col.primaryKey())
10
- .addColumn('source', 'text', (col) => col.notNull())
11
- .addColumn('event', 'text', (col) => col.notNull())
12
- .addColumn('providerEventId', 'text')
13
- .addColumn('status', 'text', (col) => col.defaultTo('pending').notNull())
9
+ .addColumn('receiptId', ctx.key, (col) => col.primaryKey())
10
+ .addColumn('source', ctx.key, (col) => col.notNull())
11
+ .addColumn('event', ctx.key, (col) => col.notNull())
12
+ .addColumn('providerEventId', ctx.key)
13
+ .addColumn('status', ctx.text, (col) => col.defaultTo(ctx.defaultText('pending')).notNull())
14
14
  .addColumn('attempts', 'integer', (col) => col.defaultTo(0).notNull())
15
- .addColumn('lastError', 'text')
15
+ .addColumn('lastError', ctx.text)
16
16
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
17
17
  .addColumn('deliveredAt', 'timestamp'),
18
18
  (db) => db.schema
@@ -24,6 +24,7 @@ export { channelSchema } from './channel.schema.js';
24
24
  export { credentialSchema } from './credential.schema.js';
25
25
  export { deploymentSchema } from './deployment.schema.js';
26
26
  export { flagSchema } from './flag.schema.js';
27
+ export { leaseSchema } from './lease.schema.js';
27
28
  export { triggerSourceSchema } from './trigger-source.schema.js';
28
29
  export { incomingWebhookSchema } from './incoming-webhook.schema.js';
29
30
  export { scopeSchema } from './scope.schema.js';
@@ -1,4 +1,4 @@
1
- import { CamelCasePlugin } from 'kysely';
1
+ import { CamelCasePlugin, sql } from 'kysely';
2
2
  import { schemaContext, } from './pikku-schema.types.js';
3
3
  import { agentSchema } from './agent.schema.js';
4
4
  import { analyticsSchema } from './analytics.schema.js';
@@ -10,6 +10,7 @@ import { scopeSchema } from './scope.schema.js';
10
10
  import { flagSchema } from './flag.schema.js';
11
11
  import { triggerSourceSchema } from './trigger-source.schema.js';
12
12
  import { incomingWebhookSchema } from './incoming-webhook.schema.js';
13
+ import { leaseSchema } from './lease.schema.js';
13
14
  import { secretSchema } from './secret.schema.js';
14
15
  import { sessionSchema } from './session.schema.js';
15
16
  import { webhookSchema } from './webhook.schema.js';
@@ -44,6 +45,7 @@ export const pikkuSchemas = [
44
45
  scopeSchema,
45
46
  flagSchema,
46
47
  triggerSourceSchema,
48
+ leaseSchema,
47
49
  auditSchema,
48
50
  analyticsSchema,
49
51
  virtualUserSchema,
@@ -56,6 +58,7 @@ export { channelSchema } from './channel.schema.js';
56
58
  export { credentialSchema } from './credential.schema.js';
57
59
  export { deploymentSchema } from './deployment.schema.js';
58
60
  export { flagSchema } from './flag.schema.js';
61
+ export { leaseSchema } from './lease.schema.js';
59
62
  export { triggerSourceSchema } from './trigger-source.schema.js';
60
63
  export { incomingWebhookSchema } from './incoming-webhook.schema.js';
61
64
  export { scopeSchema } from './scope.schema.js';
@@ -99,10 +102,30 @@ const bind = (db) => db.withPlugin(new CamelCasePlugin());
99
102
  * connection they are handed. A caller that had to remember to say the schema
100
103
  * twice would eventually say it once.
101
104
  */
102
- const boundSchema = (db) => CREATE_TABLE.exec(db.schema.createTable('pikkuSchemaProbe').addColumn('id', 'text').compile()
103
- .sql)?.[1];
104
- /** The context for statements compiled against `db`, matching its binding. */
105
- const contextFor = (db) => schemaContext(boundSchema(db));
105
+ const probe = (db) => db.schema.createTable('pikkuSchemaProbe').addColumn('id', 'text').compile()
106
+ .sql;
107
+ /**
108
+ * The context for statements compiled against `db`, matching its binding and
109
+ * its dialect — MySQL is recognised by the backtick it quotes with.
110
+ */
111
+ const contextFor = (db) => {
112
+ const sql = probe(db);
113
+ return schemaContext(CREATE_TABLE.exec(sql)?.[1], {
114
+ mysql: sql.includes('`'),
115
+ });
116
+ };
117
+ /**
118
+ * A MySQL column's full declared type — `varchar(36)`, not the `varchar` the
119
+ * introspector reports. The length is part of the type a foreign key has to
120
+ * match, and a bare `varchar` is not a type MySQL accepts.
121
+ */
122
+ const mysqlColumnType = async (db, table, column) => {
123
+ const { rows } = await sql `
124
+ select column_type as type from information_schema.columns
125
+ where table_schema = database() and table_name = ${table} and column_name = ${column}
126
+ `.execute(db);
127
+ return rows[0].type;
128
+ };
106
129
  /**
107
130
  * Look up every prerequisite in `db`, reporting the gaps and the types found.
108
131
  *
@@ -119,6 +142,7 @@ export const resolveRequirements = async (db, schemas = pikkuSchemas) => {
119
142
  table.name,
120
143
  table,
121
144
  ]));
145
+ const mysql = contextFor(bind(db)).mysql;
122
146
  const types = {};
123
147
  const unmet = [];
124
148
  for (const schema of schemas) {
@@ -130,7 +154,9 @@ export const resolveRequirements = async (db, schemas = pikkuSchemas) => {
130
154
  unmet.push({ schema, requirement });
131
155
  }
132
156
  else {
133
- types[`${requirement.table}.${requirement.column}`] = column.dataType;
157
+ types[`${requirement.table}.${requirement.column}`] = mysql
158
+ ? await mysqlColumnType(db, requirement.table, requirement.column)
159
+ : column.dataType;
134
160
  }
135
161
  }
136
162
  }
@@ -231,7 +257,7 @@ const displayName = (table) => table.schema ? `${table.schema}.${table.name}` :
231
257
  * identifier — matching the first one instead reads a `withSchema('app')`
232
258
  * connection's DDL as a table called `app`, which is no table at all.
233
259
  */
234
- const CREATE_TABLE = /^\s*create table (?:if not exists\s+)?(?:"([^"]+)"\.)?"([^"]+)"/i;
260
+ const CREATE_TABLE = /^\s*create table (?:if not exists\s+)?(?:["`]([^"`]+)["`]\.)?["`]([^"`]+)["`]/i;
235
261
  /**
236
262
  * The tables a schema creates, read back out of its own compiled SQL.
237
263
  *
@@ -0,0 +1,2 @@
1
+ import type { PikkuSchema } from './pikku-schema.types.js';
2
+ export declare const leaseSchema: PikkuSchema;
@@ -0,0 +1,12 @@
1
+ export const leaseSchema = {
2
+ name: 'lease',
3
+ ownedBy: ['leaseService', 'workflowService'],
4
+ statements: [
5
+ (db, _types, ctx) => db.schema
6
+ .createTable('pikkuLease')
7
+ .addColumn('key', ctx.key, (col) => col.primaryKey())
8
+ .addColumn('holder', ctx.text, (col) => col.notNull())
9
+ .addColumn('token', 'integer', (col) => col.notNull())
10
+ .addColumn('expiresAt', 'bigint', (col) => col.notNull()),
11
+ ],
12
+ };
@@ -1,4 +1,5 @@
1
- import { type CompiledQuery, type Kysely, type RawBuilder } from 'kysely';
1
+ import { type ColumnDefinitionBuilder, type CompiledQuery, type CreateTableBuilder, type Kysely, type RawBuilder } from 'kysely';
2
+ type DataTypeExpression = Parameters<CreateTableBuilder<any, any>['addColumn']>[1];
2
3
  /**
3
4
  * One DDL statement, bound to a database.
4
5
  *
@@ -21,6 +22,12 @@ export type SchemaStatementFactory = (db: Kysely<any>, types: RequiredTypes, ctx
21
22
  * statement that spells its own SQL has to spell the qualifier too.
22
23
  */
23
24
  export interface SchemaContext {
25
+ /**
26
+ * Whether the connection is MySQL, which is the one engine the declarations
27
+ * cannot spell the same way: it refuses a `TEXT` column in a key or with a
28
+ * literal default, and quotes with backticks.
29
+ */
30
+ mysql: boolean;
24
31
  /**
25
32
  * A table as the surrounding DDL addresses it: `"app"."credentials"` on a
26
33
  * schema-bound connection, `"credentials"` otherwise.
@@ -30,6 +37,36 @@ export interface SchemaContext {
30
37
  * physical names throughout and this is no exception.
31
38
  */
32
39
  table: (name: string) => RawBuilder<unknown>;
40
+ /**
41
+ * The type of a text column that is part of a key — primary, foreign, unique
42
+ * or indexed. `text` everywhere but MySQL, where a key needs a bounded type.
43
+ */
44
+ key: DataTypeExpression;
45
+ /**
46
+ * The type of free text of any length. `text` everywhere but MySQL, where
47
+ * `TEXT` stops at 64KB and these columns hold serialized state and payloads.
48
+ */
49
+ text: DataTypeExpression;
50
+ /**
51
+ * A literal default for a text column. MySQL refuses one on `TEXT`, so it
52
+ * takes the parenthesised expression form (8.0.13+); everywhere else the
53
+ * literal itself.
54
+ */
55
+ defaultText: (value: string) => string | RawBuilder<unknown>;
56
+ /**
57
+ * An inline `references ... on delete cascade` on a column.
58
+ *
59
+ * A no-op on MySQL, which parses an inline `REFERENCES` and discards it — the
60
+ * constraint never exists. There the same relationship is declared once, at
61
+ * table level, through `foreignKeys`.
62
+ */
63
+ references: (column: ColumnDefinitionBuilder, target: string) => ColumnDefinitionBuilder;
64
+ /**
65
+ * The table-level foreign keys MySQL needs in place of inline `references`,
66
+ * keyed by local column to `table.column` target. Identity everywhere else,
67
+ * where `references` has already done the work.
68
+ */
69
+ foreignKeys: (table: string, keys: Record<string, string>) => <T extends CreateTableBuilder<any, any>>(builder: T) => T;
33
70
  }
34
71
  /**
35
72
  * The `SchemaContext` for DDL that names no schema.
@@ -44,7 +81,9 @@ export declare const unqualifiedContext: SchemaContext;
44
81
  * Identifiers are quoted rather than interpolated bare so a schema named after
45
82
  * a reserved word still compiles.
46
83
  */
47
- export declare const schemaContext: (schema: string | undefined) => SchemaContext;
84
+ export declare const schemaContext: (schema: string | undefined, options?: {
85
+ mysql?: boolean;
86
+ }) => SchemaContext;
48
87
  /**
49
88
  * The physical types of the columns named in `requires`, keyed `table.column`.
50
89
  *
@@ -129,3 +168,4 @@ export interface SchemaRequirement {
129
168
  */
130
169
  export declare function rawStatement(fragment: RawBuilder<unknown>): SchemaStatementFactory;
131
170
  export declare function rawStatement(build: (ctx: SchemaContext) => RawBuilder<unknown>): SchemaStatementFactory;
171
+ export {};
@@ -1,22 +1,38 @@
1
- import { sql } from 'kysely';
1
+ import { sql, } from 'kysely';
2
2
  /**
3
3
  * The `SchemaContext` for DDL that names no schema.
4
4
  *
5
5
  * The default everywhere a schema has not been asked for, which is every engine
6
6
  * but postgres and most postgres projects too.
7
7
  */
8
- export const unqualifiedContext = {
9
- table: (name) => sql.raw(`"${name}"`),
10
- };
8
+ export const unqualifiedContext = buildContext(undefined, false);
9
+ function buildContext(schema, mysql) {
10
+ const quote = mysql ? '`' : '"';
11
+ const name = (table) => schema
12
+ ? `${quote}${schema}${quote}.${quote}${table}${quote}`
13
+ : `${quote}${table}${quote}`;
14
+ return {
15
+ mysql,
16
+ table: (table) => sql.raw(name(table)),
17
+ key: mysql ? sql.raw('varchar(255)') : 'text',
18
+ text: mysql ? sql.raw('longtext') : 'text',
19
+ defaultText: (value) => (mysql ? sql `(${sql.lit(value)})` : value),
20
+ references: (column, target) => mysql ? column : column.references(target).onDelete('cascade'),
21
+ foreignKeys: (table, keys) => (builder) => mysql
22
+ ? Object.entries(keys).reduce((acc, [column, target]) => {
23
+ const [targetTable, targetColumn] = target.split('.');
24
+ return acc.addForeignKeyConstraint(`${table}_${column}_fk`, [column], targetTable, [targetColumn], (fk) => fk.onDelete('cascade'));
25
+ }, builder)
26
+ : builder,
27
+ };
28
+ }
11
29
  /**
12
30
  * A `SchemaContext` qualifying every table into `schema`.
13
31
  *
14
32
  * Identifiers are quoted rather than interpolated bare so a schema named after
15
33
  * a reserved word still compiles.
16
34
  */
17
- export const schemaContext = (schema) => schema
18
- ? { table: (name) => sql.raw(`"${schema}"."${name}"`) }
19
- : unqualifiedContext;
35
+ export const schemaContext = (schema, options = {}) => buildContext(schema, options.mysql ?? false);
20
36
  /**
21
37
  * The declared type for a foreign key onto a column another source owns.
22
38
  *
@@ -21,15 +21,15 @@ export const scopeSchema = {
21
21
  ownedBy: ['scopeService'],
22
22
  requires: [{ table: 'user', column: 'id', owner: 'Better Auth' }],
23
23
  statements: [
24
- (db) => db.schema
24
+ (db, _types, ctx) => db.schema
25
25
  .createTable('pikkuScopes')
26
- .addColumn('name', 'text', (col) => col.primaryKey())
27
- .addColumn('description', 'text')
26
+ .addColumn('name', ctx.key, (col) => col.primaryKey())
27
+ .addColumn('description', ctx.text)
28
28
  .addColumn('declared', 'boolean', (col) => col.defaultTo(true).notNull()),
29
- (db) => db.schema
29
+ (db, _types, ctx) => db.schema
30
30
  .createTable('pikkuRoles')
31
- .addColumn('name', 'text', (col) => col.primaryKey())
32
- .addColumn('description', 'text')
31
+ .addColumn('name', ctx.key, (col) => col.primaryKey())
32
+ .addColumn('description', ctx.text)
33
33
  // Declared in code with `defineSystemRole` rather than composed by an
34
34
  // admin. What it buys is the refusals: a system role cannot be renamed,
35
35
  // re-scoped or deleted from the console, and a console role cannot be
@@ -43,30 +43,42 @@ export const scopeSchema = {
43
43
  // be able to cause.
44
44
  .addColumn('declared', 'boolean', (col) => col.defaultTo(true).notNull())
45
45
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
46
- (db) => db.schema
46
+ (db, _types, ctx) => db.schema
47
47
  .createTable('pikkuRoleScopes')
48
- .addColumn('role', 'text', (col) => col.notNull().references('pikkuRoles.name').onDelete('cascade'))
49
- .addColumn('scope', 'text', (col) => col.notNull().references('pikkuScopes.name').onDelete('cascade'))
50
- .addPrimaryKeyConstraint('pikku_role_scopes_pk', ['role', 'scope']),
51
- (db, types) => db.schema
48
+ .addColumn('role', ctx.key, (col) => ctx.references(col.notNull(), 'pikkuRoles.name'))
49
+ .addColumn('scope', ctx.key, (col) => ctx.references(col.notNull(), 'pikkuScopes.name'))
50
+ .addPrimaryKeyConstraint('pikku_role_scopes_pk', ['role', 'scope'])
51
+ .$call(ctx.foreignKeys('pikkuRoleScopes', {
52
+ role: 'pikkuRoles.name',
53
+ scope: 'pikkuScopes.name',
54
+ })),
55
+ (db, types, ctx) => db.schema
52
56
  .createTable('pikkuUserRole')
53
- .addColumn('userId', requiredType(types, 'user', 'id'), (col) => col.notNull().references('user.id').onDelete('cascade'))
54
- .addColumn('role', 'text', (col) => col.notNull().references('pikkuRoles.name').onDelete('cascade'))
55
- .addColumn('grantedBy', 'text')
57
+ .addColumn('userId', requiredType(types, 'user', 'id'), (col) => ctx.references(col.notNull(), 'user.id'))
58
+ .addColumn('role', ctx.key, (col) => ctx.references(col.notNull(), 'pikkuRoles.name'))
59
+ .addColumn('grantedBy', ctx.text)
56
60
  .addColumn('grantedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
57
- .addPrimaryKeyConstraint('pikku_user_role_pk', ['userId', 'role']),
58
- (db, types) => db.schema
61
+ .addPrimaryKeyConstraint('pikku_user_role_pk', ['userId', 'role'])
62
+ .$call(ctx.foreignKeys('pikkuUserRole', {
63
+ userId: 'user.id',
64
+ role: 'pikkuRoles.name',
65
+ })),
66
+ (db, types, ctx) => db.schema
59
67
  .createTable('pikkuUserScope')
60
- .addColumn('userId', requiredType(types, 'user', 'id'), (col) => col.notNull().references('user.id').onDelete('cascade'))
61
- .addColumn('scope', 'text', (col) => col.notNull().references('pikkuScopes.name').onDelete('cascade'))
62
- .addColumn('grantedBy', 'text')
68
+ .addColumn('userId', requiredType(types, 'user', 'id'), (col) => ctx.references(col.notNull(), 'user.id'))
69
+ .addColumn('scope', ctx.key, (col) => ctx.references(col.notNull(), 'pikkuScopes.name'))
70
+ .addColumn('grantedBy', ctx.text)
63
71
  .addColumn('grantedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
64
- .addPrimaryKeyConstraint('pikku_user_scope_pk', ['userId', 'scope']),
65
- (db) => db.schema
72
+ .addPrimaryKeyConstraint('pikku_user_scope_pk', ['userId', 'scope'])
73
+ .$call(ctx.foreignKeys('pikkuUserScope', {
74
+ userId: 'user.id',
75
+ scope: 'pikkuScopes.name',
76
+ })),
77
+ (db, _types, ctx) => db.schema
66
78
  .createIndex('pikku_role_scopes_scope_idx')
67
79
  .on('pikkuRoleScopes')
68
80
  .column('scope'),
69
- (db) => db.schema
81
+ (db, _types, ctx) => db.schema
70
82
  .createIndex('pikku_user_role_role_idx')
71
83
  .on('pikkuUserRole')
72
84
  .column('role'),
@@ -11,20 +11,20 @@ import { sql } from 'kysely';
11
11
  export const secretSchema = {
12
12
  name: 'secret',
13
13
  statements: [
14
- (db) => db.schema
14
+ (db, _types, ctx) => db.schema
15
15
  .createTable('secrets')
16
16
  .addColumn('key', 'varchar(255)', (col) => col.primaryKey())
17
- .addColumn('ciphertext', 'text', (col) => col.notNull())
18
- .addColumn('wrappedDek', 'text', (col) => col.notNull())
17
+ .addColumn('ciphertext', ctx.text, (col) => col.notNull())
18
+ .addColumn('wrappedDek', ctx.text, (col) => col.notNull())
19
19
  .addColumn('keyVersion', 'integer', (col) => col.notNull())
20
20
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
21
21
  .addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
22
- (db) => db.schema
22
+ (db, _types, ctx) => db.schema
23
23
  .createTable('secretKekSalts')
24
24
  .addColumn('keyVersion', 'integer', (col) => col.primaryKey())
25
25
  .addColumn('salt', 'varchar(64)', (col) => col.notNull())
26
26
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
27
- (db) => db.schema
27
+ (db, _types, ctx) => db.schema
28
28
  .createTable('secretsAudit')
29
29
  .addColumn('id', 'varchar(36)', (col) => col.primaryKey())
30
30
  .addColumn('secretKey', 'varchar(255)', (col) => col.notNull())
@@ -3,10 +3,10 @@ import { sql } from 'kysely';
3
3
  export const sessionSchema = {
4
4
  name: 'session',
5
5
  statements: [
6
- (db) => db.schema
6
+ (db, _types, ctx) => db.schema
7
7
  .createTable('pikkuUserSessions')
8
- .addColumn('pikkuUserId', 'text', (col) => col.primaryKey())
9
- .addColumn('session', 'text', (col) => col.notNull())
8
+ .addColumn('pikkuUserId', ctx.key, (col) => col.primaryKey())
9
+ .addColumn('session', ctx.text, (col) => col.notNull())
10
10
  .addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
11
11
  .addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
12
12
  ],