@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,274 @@
|
|
|
1
|
+
import { CamelCasePlugin } from 'kysely';
|
|
2
|
+
import { aiSchema } from './ai.schema.js';
|
|
3
|
+
import { channelSchema } from './channel.schema.js';
|
|
4
|
+
import { credentialSchema } from './credential.schema.js';
|
|
5
|
+
import { deploymentSchema } from './deployment.schema.js';
|
|
6
|
+
import { scopeSchema } from './scope.schema.js';
|
|
7
|
+
import { secretSchema } from './secret.schema.js';
|
|
8
|
+
import { sessionSchema } from './session.schema.js';
|
|
9
|
+
import { webhookSchema } from './webhook.schema.js';
|
|
10
|
+
import { workflowSchema } from './workflow.schema.js';
|
|
11
|
+
export { rawStatement, requiredType } from './pikku-schema.types.js';
|
|
12
|
+
/**
|
|
13
|
+
* Every table the pikku runtime needs, in dependency order.
|
|
14
|
+
*
|
|
15
|
+
* Order is load-bearing, not cosmetic: `scope` has foreign keys onto Better
|
|
16
|
+
* Auth's `user`, and the tables within each schema reference each other. Sort
|
|
17
|
+
* this list alphabetically and it stops applying.
|
|
18
|
+
*/
|
|
19
|
+
export const pikkuSchemas = [
|
|
20
|
+
channelSchema,
|
|
21
|
+
sessionSchema,
|
|
22
|
+
secretSchema,
|
|
23
|
+
credentialSchema,
|
|
24
|
+
deploymentSchema,
|
|
25
|
+
webhookSchema,
|
|
26
|
+
workflowSchema,
|
|
27
|
+
aiSchema,
|
|
28
|
+
scopeSchema,
|
|
29
|
+
];
|
|
30
|
+
/**
|
|
31
|
+
* Bind a declaration to a database.
|
|
32
|
+
*
|
|
33
|
+
* The plugin is applied here rather than trusted to the caller. Declarations
|
|
34
|
+
* name tables and columns the way the rest of the codebase queries them
|
|
35
|
+
* (`aiThreads.resourceId`), and it is `CamelCasePlugin` that turns those into
|
|
36
|
+
* the physical `ai_threads.resource_id`. Without it the same declaration
|
|
37
|
+
* silently creates quoted camelCase tables that nothing can read.
|
|
38
|
+
*/
|
|
39
|
+
const bind = (db) => db.withPlugin(new CamelCasePlugin());
|
|
40
|
+
/**
|
|
41
|
+
* Look up every prerequisite in `db`, reporting the gaps and the types found.
|
|
42
|
+
*
|
|
43
|
+
* Both halves come from one introspection because they are one question. The
|
|
44
|
+
* gaps say whether a schema can be applied at all; the types say how — a
|
|
45
|
+
* foreign key onto a `uuid` primary key has to be declared `uuid`, and guessing
|
|
46
|
+
* `text` produces DDL the database refuses.
|
|
47
|
+
*
|
|
48
|
+
* Introspection returns physical names, so `requires` is written in physical
|
|
49
|
+
* names too — this is the one place in the declaration that is not camelCase.
|
|
50
|
+
*/
|
|
51
|
+
export const resolveRequirements = async (db, schemas = pikkuSchemas) => {
|
|
52
|
+
const tables = new Map((await bind(db).introspection.getTables()).map((table) => [
|
|
53
|
+
table.name,
|
|
54
|
+
table,
|
|
55
|
+
]));
|
|
56
|
+
const types = {};
|
|
57
|
+
const unmet = [];
|
|
58
|
+
for (const schema of schemas) {
|
|
59
|
+
for (const requirement of schema.requires ?? []) {
|
|
60
|
+
const column = tables
|
|
61
|
+
.get(requirement.table)
|
|
62
|
+
?.columns.find((c) => c.name === requirement.column);
|
|
63
|
+
if (!column) {
|
|
64
|
+
unmet.push({ schema, requirement });
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
types[`${requirement.table}.${requirement.column}`] = column.dataType;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { types, unmet };
|
|
72
|
+
};
|
|
73
|
+
/**
|
|
74
|
+
* The prerequisites `db` does not satisfy, as a list rather than a throw.
|
|
75
|
+
*
|
|
76
|
+
* Separate from `applyPikkuSchemas` because the two callers want opposite
|
|
77
|
+
* things from the same answer: a service booting cannot proceed without its
|
|
78
|
+
* tables, while a tool asking what a project's schema *would* be needs to
|
|
79
|
+
* describe the gap rather than die on it.
|
|
80
|
+
*/
|
|
81
|
+
export const unsatisfiedRequirements = async (db, schemas = pikkuSchemas) => (await resolveRequirements(db, schemas)).unmet;
|
|
82
|
+
/**
|
|
83
|
+
* Create the tables, in order, on a database that has whatever they require.
|
|
84
|
+
*
|
|
85
|
+
* Prerequisites are checked before anything is created, so a project missing
|
|
86
|
+
* one is told which schema wanted what — rather than being handed a foreign key
|
|
87
|
+
* error, or a half-applied database to clean up.
|
|
88
|
+
*
|
|
89
|
+
* The statements then run in one transaction, so the same promise holds for a
|
|
90
|
+
* failure the check cannot foresee: a statement that dies mid-list takes the
|
|
91
|
+
* ones before it with it. On an engine without transactional DDL the rollback
|
|
92
|
+
* is the engine's to give, not ours.
|
|
93
|
+
*/
|
|
94
|
+
export const applyPikkuSchemas = async (db, schemas = pikkuSchemas) => {
|
|
95
|
+
const { types, unmet } = await resolveRequirements(db, schemas);
|
|
96
|
+
if (unmet.length > 0) {
|
|
97
|
+
throw new Error(unmet
|
|
98
|
+
.map(({ schema, requirement }) => `The '${schema.name}' schema requires '${requirement.table}.${requirement.column}', which nothing has created. ` +
|
|
99
|
+
`${requirement.owner} owns it — apply its schema first.`)
|
|
100
|
+
.join('\n'));
|
|
101
|
+
}
|
|
102
|
+
await db.transaction().execute(async (trx) => {
|
|
103
|
+
const bound = bind(trx);
|
|
104
|
+
for (const schema of schemas) {
|
|
105
|
+
for (const statement of schema.statements) {
|
|
106
|
+
const query = statement(bound, types);
|
|
107
|
+
try {
|
|
108
|
+
await query.execute();
|
|
109
|
+
}
|
|
110
|
+
catch (error) {
|
|
111
|
+
throw explain(error, query.compile().sql, schema);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
};
|
|
117
|
+
/** `references "app"."workflow_runs"` — a foreign key onto a qualified table. */
|
|
118
|
+
const QUALIFIED_REFERENCE = /references "[^"]+"\."[^"]+"/i;
|
|
119
|
+
/**
|
|
120
|
+
* sqlite's own words for "you put a dot where a table name goes".
|
|
121
|
+
*
|
|
122
|
+
* Matched rather than a driver's `code`, because every sqlite binding
|
|
123
|
+
* (better-sqlite3, node:sqlite, bun:sqlite, libsql) passes the engine's message
|
|
124
|
+
* through verbatim while disagreeing about how to label it. Postgres phrases the
|
|
125
|
+
* same class of error the other way round — `syntax error at or near "."` — so
|
|
126
|
+
* this cannot fire on it.
|
|
127
|
+
*/
|
|
128
|
+
const SQLITE_REJECTED_QUALIFIER = /near "\.":\s*syntax error/i;
|
|
129
|
+
/**
|
|
130
|
+
* Say what a schema-bound connection did to the DDL, when that is what failed.
|
|
131
|
+
*
|
|
132
|
+
* `withSchema(...)` qualifies foreign key targets along with everything else.
|
|
133
|
+
* Postgres needs that — an unqualified reference resolves against `search_path`,
|
|
134
|
+
* which a schema-bound connection does not control — and sqlite refuses it: a
|
|
135
|
+
* `REFERENCES` clause there takes a bare table name, because a foreign key can
|
|
136
|
+
* only point inside the same database, so there is no qualifier to give. One
|
|
137
|
+
* declaration cannot spell both, and what the engine says about it is
|
|
138
|
+
* `near ".": syntax error`, which names neither the schema nor the cause.
|
|
139
|
+
*
|
|
140
|
+
* Gated on sqlite's exact phrasing as well as the SQL. Postgres compiles the
|
|
141
|
+
* same qualified reference legitimately, and can raise a syntax error of its own
|
|
142
|
+
* on this DDL — a column type resolved from `requires` goes in verbatim — so a
|
|
143
|
+
* looser `/syntax error/` would answer for postgres in sqlite's name.
|
|
144
|
+
*/
|
|
145
|
+
const explain = (error, sql, schema) => {
|
|
146
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
147
|
+
if (!QUALIFIED_REFERENCE.test(sql) ||
|
|
148
|
+
!SQLITE_REJECTED_QUALIFIER.test(message)) {
|
|
149
|
+
return error;
|
|
150
|
+
}
|
|
151
|
+
return new Error(`The '${schema.name}' schema has a foreign key, and this connection is bound to a schema, ` +
|
|
152
|
+
`so it compiled to a qualified 'references "schema"."table"' that the database rejected. ` +
|
|
153
|
+
'sqlite is the engine that does this: its REFERENCES clause takes a bare table name. ' +
|
|
154
|
+
'Use a connection without withSchema(...) there — sqlite has one schema to be in — ' +
|
|
155
|
+
'or write the schema down as a migration with `pikku db generate` and apply it yourself. ' +
|
|
156
|
+
`The database said: ${message}`, { cause: error });
|
|
157
|
+
};
|
|
158
|
+
/** How a table reads in an error message: qualified only when it is. */
|
|
159
|
+
const displayName = (table) => table.schema ? `${table.schema}.${table.name}` : table.name;
|
|
160
|
+
/**
|
|
161
|
+
* `create table "workflow_runs"` and `create table "app"."workflow_runs"`.
|
|
162
|
+
*
|
|
163
|
+
* The schema half is optional and the table is always the LAST quoted
|
|
164
|
+
* identifier — matching the first one instead reads a `withSchema('app')`
|
|
165
|
+
* connection's DDL as a table called `app`, which is no table at all.
|
|
166
|
+
*/
|
|
167
|
+
const CREATE_TABLE = /^\s*create table (?:if not exists\s+)?(?:"([^"]+)"\.)?"([^"]+)"/i;
|
|
168
|
+
/**
|
|
169
|
+
* The tables a schema creates, read back out of its own compiled SQL.
|
|
170
|
+
*
|
|
171
|
+
* Derived rather than declared alongside the statements, because a hand-kept
|
|
172
|
+
* list of names is a second source of truth: rename a table in a statement and
|
|
173
|
+
* the list keeps answering for the old one.
|
|
174
|
+
*
|
|
175
|
+
* Compiled against `db` rather than in the abstract, so the answer carries the
|
|
176
|
+
* schema the DDL will actually target — the same binding the lookup has to use.
|
|
177
|
+
*/
|
|
178
|
+
export const declaredTables = (schema, db) => {
|
|
179
|
+
const bound = bind(db);
|
|
180
|
+
const tables = [];
|
|
181
|
+
for (const statement of schema.statements) {
|
|
182
|
+
const match = CREATE_TABLE.exec(statement(bound, {}).compile().sql);
|
|
183
|
+
if (match)
|
|
184
|
+
tables.push({ schema: match[1], name: match[2] });
|
|
185
|
+
}
|
|
186
|
+
return tables;
|
|
187
|
+
};
|
|
188
|
+
const tablesOf = async (db) => {
|
|
189
|
+
const tables = await bind(db).introspection.getTables();
|
|
190
|
+
return {
|
|
191
|
+
qualified: new Set(tables
|
|
192
|
+
.filter((table) => table.schema)
|
|
193
|
+
.map((table) => `${table.schema}.${table.name}`)),
|
|
194
|
+
bare: new Set(tables.map((table) => table.name)),
|
|
195
|
+
schemaAware: tables.some((table) => table.schema !== undefined),
|
|
196
|
+
};
|
|
197
|
+
};
|
|
198
|
+
/**
|
|
199
|
+
* Qualified declarations match on the pair — but only where that pair means
|
|
200
|
+
* something.
|
|
201
|
+
*
|
|
202
|
+
* `TableMetadata.schema` is optional, and sqlite and mysql leave it unset:
|
|
203
|
+
* sqlite has one schema to be in, and mysql calls the thing a database. A
|
|
204
|
+
* `withSchema('main')` connection there declares `main.workflow_runs` while
|
|
205
|
+
* introspection can only offer `workflow_runs`, and holding out for the pair
|
|
206
|
+
* would read every existing table as missing — the same false negative this
|
|
207
|
+
* whole function exists to fix, arrived at from the other side. So when the
|
|
208
|
+
* engine reports no schemas, the bare name is the only name there is.
|
|
209
|
+
*/
|
|
210
|
+
const isPresent = (existing, table) => table.schema && existing.schemaAware
|
|
211
|
+
? existing.qualified.has(`${table.schema}.${table.name}`)
|
|
212
|
+
: existing.bare.has(table.name);
|
|
213
|
+
/**
|
|
214
|
+
* Make sure one schema's tables are there, creating them only if none are.
|
|
215
|
+
*
|
|
216
|
+
* What a service calls at boot. It looks before it creates, which is the whole
|
|
217
|
+
* difference from the `.ifNotExists()` DDL this replaces: that spelling turned
|
|
218
|
+
* every failure into a silent no-op, and hid a foreign key onto `user.id` that
|
|
219
|
+
* postgres had been rejecting since the day it was written.
|
|
220
|
+
*
|
|
221
|
+
* A partially-present schema is refused rather than completed. Half a schema
|
|
222
|
+
* means something else already applied part of it — a migration, an older
|
|
223
|
+
* version, a hand-run script — and creating the remainder at boot would leave
|
|
224
|
+
* two authorities over one set of tables, which is the condition all of this
|
|
225
|
+
* exists to end.
|
|
226
|
+
*
|
|
227
|
+
* Creating at boot is the fallback, not the intent. `pikku db generate` writes
|
|
228
|
+
* the declaration down as a migration; a project that has done so takes the
|
|
229
|
+
* `present` path and this never issues DDL at all.
|
|
230
|
+
*/
|
|
231
|
+
export const ensurePikkuSchema = async (db, schema) => {
|
|
232
|
+
const existing = await tablesOf(db);
|
|
233
|
+
const declared = declaredTables(schema, db);
|
|
234
|
+
const missing = declared.filter((table) => !isPresent(existing, table));
|
|
235
|
+
if (missing.length === 0)
|
|
236
|
+
return 'present';
|
|
237
|
+
if (missing.length < declared.length) {
|
|
238
|
+
throw new Error(`The '${schema.name}' schema is half applied: ${missing.map(displayName).join(', ')} missing, ` +
|
|
239
|
+
`${declared
|
|
240
|
+
.filter((table) => isPresent(existing, table))
|
|
241
|
+
.map(displayName)
|
|
242
|
+
.join(', ')} already there. ` +
|
|
243
|
+
'Something else owns part of it. Reconcile it with a migration — ' +
|
|
244
|
+
'`pikku db generate` writes the declaration down — rather than creating the rest at boot.');
|
|
245
|
+
}
|
|
246
|
+
try {
|
|
247
|
+
await applyPikkuSchemas(db, [schema]);
|
|
248
|
+
return 'created';
|
|
249
|
+
}
|
|
250
|
+
catch (error) {
|
|
251
|
+
// Two instances booting cold against one database can both see it empty
|
|
252
|
+
// before either has finished creating. If the other one got there, say so;
|
|
253
|
+
// anything else is a real failure and still throws. This is a courtesy, not
|
|
254
|
+
// a guarantee — a boot race is only fully answered by having run the
|
|
255
|
+
// migration, which is what `pikku db generate` is for.
|
|
256
|
+
const after = await tablesOf(db);
|
|
257
|
+
if (declared.every((table) => isPresent(after, table)))
|
|
258
|
+
return 'present';
|
|
259
|
+
throw error;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
262
|
+
/**
|
|
263
|
+
* Render the declaration as SQL for the dialect `db` was built with.
|
|
264
|
+
*
|
|
265
|
+
* `types` comes from `resolveRequirements` against the database the migration
|
|
266
|
+
* is being written for. Omit it and foreign keys onto another source's columns
|
|
267
|
+
* compile as `text`, which is a shape, not a migration.
|
|
268
|
+
*/
|
|
269
|
+
export const compilePikkuSchemas = (db, schemas = pikkuSchemas, types = {}) => {
|
|
270
|
+
const bound = bind(db);
|
|
271
|
+
return schemas
|
|
272
|
+
.flatMap((schema) => schema.statements.map((statement) => `${statement(bound, types).compile().sql};`))
|
|
273
|
+
.join('\n\n');
|
|
274
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { type CompiledQuery, type Kysely, type RawBuilder } from 'kysely';
|
|
2
|
+
/**
|
|
3
|
+
* One DDL statement, bound to a database.
|
|
4
|
+
*
|
|
5
|
+
* Bound rather than free-standing because the same declaration serves two
|
|
6
|
+
* purposes that need the same object: applied to a throwaway database it yields
|
|
7
|
+
* the schema to introspect, and compiled against a dialect it yields the SQL a
|
|
8
|
+
* migration is written from.
|
|
9
|
+
*/
|
|
10
|
+
export interface SchemaStatement {
|
|
11
|
+
execute(): Promise<unknown>;
|
|
12
|
+
compile(): CompiledQuery;
|
|
13
|
+
}
|
|
14
|
+
export type SchemaStatementFactory = (db: Kysely<any>, types: RequiredTypes) => SchemaStatement;
|
|
15
|
+
/**
|
|
16
|
+
* The physical types of the columns named in `requires`, keyed `table.column`.
|
|
17
|
+
*
|
|
18
|
+
* Introspected from the database the schemas are being applied to, because a
|
|
19
|
+
* foreign key column has to be declared with the type of the column it points
|
|
20
|
+
* at and that column belongs to somebody else.
|
|
21
|
+
*/
|
|
22
|
+
export type RequiredTypes = Record<string, string>;
|
|
23
|
+
/**
|
|
24
|
+
* The declared type for a foreign key onto a column another source owns.
|
|
25
|
+
*
|
|
26
|
+
* Falls back to `text` when nothing has been introspected — the case when the
|
|
27
|
+
* declaration is compiled without a database, where the real type is unknowable
|
|
28
|
+
* and only the shape of the statement is being inspected.
|
|
29
|
+
*/
|
|
30
|
+
export declare const requiredType: (types: RequiredTypes, table: string, column: string) => RawBuilder<unknown>;
|
|
31
|
+
/**
|
|
32
|
+
* The tables one part of the pikku runtime needs, declared rather than created.
|
|
33
|
+
*
|
|
34
|
+
* `name` labels the generated migration and the drift report, so it is part of
|
|
35
|
+
* the contract — changing it orphans the migration that was written under the
|
|
36
|
+
* old one.
|
|
37
|
+
*/
|
|
38
|
+
export interface PikkuSchema {
|
|
39
|
+
name: string;
|
|
40
|
+
statements: SchemaStatementFactory[];
|
|
41
|
+
/**
|
|
42
|
+
* Tables this schema references but does not create, which something else
|
|
43
|
+
* must have created first.
|
|
44
|
+
*
|
|
45
|
+
* Declared rather than discovered so the failure arrives as a sentence naming
|
|
46
|
+
* what is missing and who owns it, instead of a foreign key error from the
|
|
47
|
+
* database — or, worse, a stub table conjured up to make the error go away.
|
|
48
|
+
*/
|
|
49
|
+
requires?: SchemaRequirement[];
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* A column another source owns, which one of these schemas depends on.
|
|
53
|
+
*
|
|
54
|
+
* Named down to the column, not just the table, because that is the granularity
|
|
55
|
+
* the dependency actually has: a foreign key needs the referenced column to
|
|
56
|
+
* exist *and* needs its type in order to declare a matching one.
|
|
57
|
+
*
|
|
58
|
+
* `owner` is carried in the declaration rather than composed into the error at
|
|
59
|
+
* the point it is thrown, so adding a second prerequisite cannot leave behind a
|
|
60
|
+
* message that still names the first one's owner.
|
|
61
|
+
*/
|
|
62
|
+
export interface SchemaRequirement {
|
|
63
|
+
table: string;
|
|
64
|
+
column: string;
|
|
65
|
+
owner: string;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Wrap a raw SQL fragment as a statement.
|
|
69
|
+
*
|
|
70
|
+
* For the DDL kysely's schema builder cannot express — a unique index over an
|
|
71
|
+
* expression, say. Note that raw SQL is *not* rewritten by `withSchema`, so a
|
|
72
|
+
* fragment naming a table unqualified will resolve against the connection's
|
|
73
|
+
* search path rather than the schema the rest of the declaration lands in.
|
|
74
|
+
*/
|
|
75
|
+
export declare const rawStatement: (fragment: RawBuilder<unknown>) => SchemaStatementFactory;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
/**
|
|
3
|
+
* The declared type for a foreign key onto a column another source owns.
|
|
4
|
+
*
|
|
5
|
+
* Falls back to `text` when nothing has been introspected — the case when the
|
|
6
|
+
* declaration is compiled without a database, where the real type is unknowable
|
|
7
|
+
* and only the shape of the statement is being inspected.
|
|
8
|
+
*/
|
|
9
|
+
export const requiredType = (types, table, column) => sql.raw(types[`${table}.${column}`] ?? 'text');
|
|
10
|
+
/**
|
|
11
|
+
* Wrap a raw SQL fragment as a statement.
|
|
12
|
+
*
|
|
13
|
+
* For the DDL kysely's schema builder cannot express — a unique index over an
|
|
14
|
+
* expression, say. Note that raw SQL is *not* rewritten by `withSchema`, so a
|
|
15
|
+
* fragment naming a table unqualified will resolve against the connection's
|
|
16
|
+
* search path rather than the schema the rest of the declaration lands in.
|
|
17
|
+
*/
|
|
18
|
+
export const rawStatement = (fragment) => (db) => ({
|
|
19
|
+
execute: () => fragment.execute(db),
|
|
20
|
+
compile: () => fragment.compile(db),
|
|
21
|
+
});
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type PikkuSchema } from './pikku-schema.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Scopes, roles and the grants that bind them to users.
|
|
4
|
+
*
|
|
5
|
+
* `pikkuUserRole` and `pikkuUserScope` reference `user.id`, which Better Auth
|
|
6
|
+
* owns. Auth is a prerequisite rather than an optional companion: a grant is
|
|
7
|
+
* made to a user, so without the table the user lives in there is nothing to
|
|
8
|
+
* grant to, and the cascade that revokes grants when a user is deleted has
|
|
9
|
+
* nothing to hang off.
|
|
10
|
+
*
|
|
11
|
+
* `userId` takes its type from `user.id` rather than declaring one, because
|
|
12
|
+
* that type is Better Auth's to decide: `text` by default, `uuid` under
|
|
13
|
+
* `generateId: 'uuid'`, an identity `integer` under `'serial'`. The old
|
|
14
|
+
* hand-written DDL said `text`, and postgres rejects a `text` column
|
|
15
|
+
* referencing either of the others — so the whole statement failed and projects
|
|
16
|
+
* wrote these tables by hand instead.
|
|
17
|
+
*/
|
|
18
|
+
export declare const scopeSchema: PikkuSchema;
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
import { requiredType } from './pikku-schema.types.js';
|
|
3
|
+
/**
|
|
4
|
+
* Scopes, roles and the grants that bind them to users.
|
|
5
|
+
*
|
|
6
|
+
* `pikkuUserRole` and `pikkuUserScope` reference `user.id`, which Better Auth
|
|
7
|
+
* owns. Auth is a prerequisite rather than an optional companion: a grant is
|
|
8
|
+
* made to a user, so without the table the user lives in there is nothing to
|
|
9
|
+
* grant to, and the cascade that revokes grants when a user is deleted has
|
|
10
|
+
* nothing to hang off.
|
|
11
|
+
*
|
|
12
|
+
* `userId` takes its type from `user.id` rather than declaring one, because
|
|
13
|
+
* that type is Better Auth's to decide: `text` by default, `uuid` under
|
|
14
|
+
* `generateId: 'uuid'`, an identity `integer` under `'serial'`. The old
|
|
15
|
+
* hand-written DDL said `text`, and postgres rejects a `text` column
|
|
16
|
+
* referencing either of the others — so the whole statement failed and projects
|
|
17
|
+
* wrote these tables by hand instead.
|
|
18
|
+
*/
|
|
19
|
+
export const scopeSchema = {
|
|
20
|
+
name: 'scope',
|
|
21
|
+
requires: [{ table: 'user', column: 'id', owner: 'Better Auth' }],
|
|
22
|
+
statements: [
|
|
23
|
+
(db) => db.schema
|
|
24
|
+
.createTable('pikkuScopes')
|
|
25
|
+
.addColumn('name', 'text', (col) => col.primaryKey())
|
|
26
|
+
.addColumn('description', 'text')
|
|
27
|
+
.addColumn('declared', 'boolean', (col) => col.defaultTo(true).notNull()),
|
|
28
|
+
(db) => db.schema
|
|
29
|
+
.createTable('pikkuRoles')
|
|
30
|
+
.addColumn('name', 'text', (col) => col.primaryKey())
|
|
31
|
+
.addColumn('description', 'text')
|
|
32
|
+
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
|
|
33
|
+
(db) => db.schema
|
|
34
|
+
.createTable('pikkuRoleScopes')
|
|
35
|
+
.addColumn('role', 'text', (col) => col.notNull().references('pikkuRoles.name').onDelete('cascade'))
|
|
36
|
+
.addColumn('scope', 'text', (col) => col.notNull().references('pikkuScopes.name').onDelete('cascade'))
|
|
37
|
+
.addPrimaryKeyConstraint('pikku_role_scopes_pk', ['role', 'scope']),
|
|
38
|
+
(db, types) => db.schema
|
|
39
|
+
.createTable('pikkuUserRole')
|
|
40
|
+
.addColumn('userId', requiredType(types, 'user', 'id'), (col) => col.notNull().references('user.id').onDelete('cascade'))
|
|
41
|
+
.addColumn('role', 'text', (col) => col.notNull().references('pikkuRoles.name').onDelete('cascade'))
|
|
42
|
+
.addColumn('grantedBy', 'text')
|
|
43
|
+
.addColumn('grantedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
44
|
+
.addPrimaryKeyConstraint('pikku_user_role_pk', ['userId', 'role']),
|
|
45
|
+
(db, types) => db.schema
|
|
46
|
+
.createTable('pikkuUserScope')
|
|
47
|
+
.addColumn('userId', requiredType(types, 'user', 'id'), (col) => col.notNull().references('user.id').onDelete('cascade'))
|
|
48
|
+
.addColumn('scope', 'text', (col) => col.notNull().references('pikkuScopes.name').onDelete('cascade'))
|
|
49
|
+
.addColumn('grantedBy', 'text')
|
|
50
|
+
.addColumn('grantedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
51
|
+
.addPrimaryKeyConstraint('pikku_user_scope_pk', ['userId', 'scope']),
|
|
52
|
+
(db) => db.schema
|
|
53
|
+
.createIndex('pikku_role_scopes_scope_idx')
|
|
54
|
+
.on('pikkuRoleScopes')
|
|
55
|
+
.column('scope'),
|
|
56
|
+
(db) => db.schema
|
|
57
|
+
.createIndex('pikku_user_role_role_idx')
|
|
58
|
+
.on('pikkuUserRole')
|
|
59
|
+
.column('role'),
|
|
60
|
+
],
|
|
61
|
+
};
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { PikkuSchema } from './pikku-schema.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Encrypted secrets and their audit trail.
|
|
4
|
+
*
|
|
5
|
+
* The audit table is declared unconditionally, where `init()` created it only
|
|
6
|
+
* when the service was constructed with auditing on. A migration cannot depend
|
|
7
|
+
* on runtime configuration, and the asymmetry favours declaring it: an unused
|
|
8
|
+
* table costs nothing, while turning auditing on against a database that never
|
|
9
|
+
* got the table fails on the first write.
|
|
10
|
+
*/
|
|
11
|
+
export declare const secretSchema: PikkuSchema;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
/**
|
|
3
|
+
* Encrypted secrets and their audit trail.
|
|
4
|
+
*
|
|
5
|
+
* The audit table is declared unconditionally, where `init()` created it only
|
|
6
|
+
* when the service was constructed with auditing on. A migration cannot depend
|
|
7
|
+
* on runtime configuration, and the asymmetry favours declaring it: an unused
|
|
8
|
+
* table costs nothing, while turning auditing on against a database that never
|
|
9
|
+
* got the table fails on the first write.
|
|
10
|
+
*/
|
|
11
|
+
export const secretSchema = {
|
|
12
|
+
name: 'secret',
|
|
13
|
+
statements: [
|
|
14
|
+
(db) => db.schema
|
|
15
|
+
.createTable('secrets')
|
|
16
|
+
.addColumn('key', 'varchar(255)', (col) => col.primaryKey())
|
|
17
|
+
.addColumn('ciphertext', 'text', (col) => col.notNull())
|
|
18
|
+
.addColumn('wrappedDek', 'text', (col) => col.notNull())
|
|
19
|
+
.addColumn('keyVersion', 'integer', (col) => col.notNull())
|
|
20
|
+
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
21
|
+
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
|
|
22
|
+
(db) => db.schema
|
|
23
|
+
.createTable('secretsAudit')
|
|
24
|
+
.addColumn('id', 'varchar(36)', (col) => col.primaryKey())
|
|
25
|
+
.addColumn('secretKey', 'varchar(255)', (col) => col.notNull())
|
|
26
|
+
.addColumn('action', 'varchar(20)', (col) => col.notNull())
|
|
27
|
+
.addColumn('performedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
|
|
28
|
+
],
|
|
29
|
+
};
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
/** Server-side session storage. */
|
|
3
|
+
export const sessionSchema = {
|
|
4
|
+
name: 'session',
|
|
5
|
+
statements: [
|
|
6
|
+
(db) => db.schema
|
|
7
|
+
.createTable('pikkuUserSessions')
|
|
8
|
+
.addColumn('pikkuUserId', 'text', (col) => col.primaryKey())
|
|
9
|
+
.addColumn('session', 'text', (col) => col.notNull())
|
|
10
|
+
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
11
|
+
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
|
|
12
|
+
],
|
|
13
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
/** Webhook deliveries and one row per delivery attempt. */
|
|
3
|
+
export const webhookSchema = {
|
|
4
|
+
name: 'webhook',
|
|
5
|
+
statements: [
|
|
6
|
+
(db) => db.schema
|
|
7
|
+
.createTable('webhookDelivery')
|
|
8
|
+
.addColumn('deliveryId', 'text', (col) => col.primaryKey())
|
|
9
|
+
.addColumn('organizationId', 'text')
|
|
10
|
+
.addColumn('url', 'text', (col) => col.notNull())
|
|
11
|
+
.addColumn('event', 'text')
|
|
12
|
+
.addColumn('status', 'text', (col) => col.defaultTo('pending').notNull())
|
|
13
|
+
.addColumn('attempts', 'integer', (col) => col.defaultTo(0).notNull())
|
|
14
|
+
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
15
|
+
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
16
|
+
.addColumn('deliveredAt', 'timestamp'),
|
|
17
|
+
(db) => db.schema
|
|
18
|
+
.createTable('webhookDeliveryAttempt')
|
|
19
|
+
.addColumn('attemptId', 'text', (col) => col.primaryKey())
|
|
20
|
+
.addColumn('deliveryId', 'text', (col) => col
|
|
21
|
+
.notNull()
|
|
22
|
+
.references('webhookDelivery.deliveryId')
|
|
23
|
+
.onDelete('cascade'))
|
|
24
|
+
.addColumn('attemptNumber', 'integer', (col) => col.notNull())
|
|
25
|
+
.addColumn('statusCode', 'integer')
|
|
26
|
+
.addColumn('responseBody', 'text')
|
|
27
|
+
.addColumn('error', 'text')
|
|
28
|
+
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
|
|
29
|
+
(db) => db.schema
|
|
30
|
+
.createIndex('idx_webhook_delivery_org')
|
|
31
|
+
.on('webhookDelivery')
|
|
32
|
+
.column('organizationId'),
|
|
33
|
+
(db) => db.schema
|
|
34
|
+
.createIndex('idx_webhook_delivery_attempt_delivery')
|
|
35
|
+
.on('webhookDeliveryAttempt')
|
|
36
|
+
.column('deliveryId'),
|
|
37
|
+
],
|
|
38
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PikkuSchema } from './pikku-schema.types.js';
|
|
2
|
+
/**
|
|
3
|
+
* Workflow runs, their steps, per-step history and the versioned graphs.
|
|
4
|
+
*
|
|
5
|
+
* Two things from the boot-time DDL are deliberately not carried over. The
|
|
6
|
+
* primary keys had `defaultTo(sql.raw("'" + crypto.randomUUID() + "'"))`, which
|
|
7
|
+
* evaluates once while the statement is built — every row would have taken the
|
|
8
|
+
* same default, so it was never a generator; the services supply the id. And
|
|
9
|
+
* `workflowStep.fromStepName` was backfilled by an `alterTable(...).catch(() =>
|
|
10
|
+
* {})` bolted on after the fact, which is the job a migration does.
|
|
11
|
+
*
|
|
12
|
+
* `KyselyWorkflowMirror` created these same four tables separately. There is one
|
|
13
|
+
* declaration now, and both services read it.
|
|
14
|
+
*/
|
|
15
|
+
export declare const workflowSchema: PikkuSchema;
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { sql } from 'kysely';
|
|
2
|
+
/**
|
|
3
|
+
* Workflow runs, their steps, per-step history and the versioned graphs.
|
|
4
|
+
*
|
|
5
|
+
* Two things from the boot-time DDL are deliberately not carried over. The
|
|
6
|
+
* primary keys had `defaultTo(sql.raw("'" + crypto.randomUUID() + "'"))`, which
|
|
7
|
+
* evaluates once while the statement is built — every row would have taken the
|
|
8
|
+
* same default, so it was never a generator; the services supply the id. And
|
|
9
|
+
* `workflowStep.fromStepName` was backfilled by an `alterTable(...).catch(() =>
|
|
10
|
+
* {})` bolted on after the fact, which is the job a migration does.
|
|
11
|
+
*
|
|
12
|
+
* `KyselyWorkflowMirror` created these same four tables separately. There is one
|
|
13
|
+
* declaration now, and both services read it.
|
|
14
|
+
*/
|
|
15
|
+
export const workflowSchema = {
|
|
16
|
+
name: 'workflow',
|
|
17
|
+
statements: [
|
|
18
|
+
(db) => db.schema
|
|
19
|
+
.createTable('workflowRuns')
|
|
20
|
+
.addColumn('workflowRunId', 'text', (col) => col.primaryKey())
|
|
21
|
+
.addColumn('workflow', 'text', (col) => col.notNull())
|
|
22
|
+
.addColumn('status', 'text', (col) => col.notNull())
|
|
23
|
+
.addColumn('input', 'text', (col) => col.notNull())
|
|
24
|
+
.addColumn('output', 'text')
|
|
25
|
+
.addColumn('error', 'text')
|
|
26
|
+
.addColumn('state', 'text', (col) => col.defaultTo('{}'))
|
|
27
|
+
.addColumn('inline', 'boolean', (col) => col.defaultTo(false))
|
|
28
|
+
.addColumn('graphHash', 'text')
|
|
29
|
+
.addColumn('deterministic', 'boolean', (col) => col.defaultTo(false))
|
|
30
|
+
.addColumn('plannedSteps', 'text')
|
|
31
|
+
.addColumn('wire', 'text')
|
|
32
|
+
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
33
|
+
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull()),
|
|
34
|
+
(db) => db.schema
|
|
35
|
+
.createTable('workflowStep')
|
|
36
|
+
.addColumn('workflowStepId', 'text', (col) => col.primaryKey())
|
|
37
|
+
.addColumn('workflowRunId', 'text', (col) => col
|
|
38
|
+
.notNull()
|
|
39
|
+
.references('workflowRuns.workflowRunId')
|
|
40
|
+
.onDelete('cascade'))
|
|
41
|
+
.addColumn('stepName', 'text', (col) => col.notNull())
|
|
42
|
+
.addColumn('rpcName', 'text')
|
|
43
|
+
.addColumn('data', 'text')
|
|
44
|
+
.addColumn('status', 'text', (col) => col.notNull().defaultTo('pending'))
|
|
45
|
+
.addColumn('result', 'text')
|
|
46
|
+
.addColumn('error', 'text')
|
|
47
|
+
.addColumn('childRunId', 'text')
|
|
48
|
+
.addColumn('branchTaken', 'text')
|
|
49
|
+
.addColumn('retries', 'integer')
|
|
50
|
+
.addColumn('retryDelay', 'text')
|
|
51
|
+
.addColumn('fromStepName', 'text')
|
|
52
|
+
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
53
|
+
.addColumn('updatedAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
54
|
+
.addUniqueConstraint('workflow_step_run_name_unique', [
|
|
55
|
+
'workflowRunId',
|
|
56
|
+
'stepName',
|
|
57
|
+
]),
|
|
58
|
+
(db) => db.schema
|
|
59
|
+
.createTable('workflowStepHistory')
|
|
60
|
+
.addColumn('historyId', 'text', (col) => col.primaryKey())
|
|
61
|
+
.addColumn('workflowStepId', 'text', (col) => col
|
|
62
|
+
.notNull()
|
|
63
|
+
.references('workflowStep.workflowStepId')
|
|
64
|
+
.onDelete('cascade'))
|
|
65
|
+
.addColumn('status', 'text', (col) => col.notNull())
|
|
66
|
+
.addColumn('result', 'text')
|
|
67
|
+
.addColumn('error', 'text')
|
|
68
|
+
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
69
|
+
.addColumn('runningAt', 'timestamp')
|
|
70
|
+
.addColumn('scheduledAt', 'timestamp')
|
|
71
|
+
.addColumn('succeededAt', 'timestamp')
|
|
72
|
+
.addColumn('failedAt', 'timestamp'),
|
|
73
|
+
(db) => db.schema
|
|
74
|
+
.createTable('workflowVersions')
|
|
75
|
+
.addColumn('workflowName', 'text', (col) => col.notNull())
|
|
76
|
+
.addColumn('graphHash', 'text', (col) => col.notNull())
|
|
77
|
+
.addColumn('graph', 'text', (col) => col.notNull())
|
|
78
|
+
.addColumn('source', 'text', (col) => col.notNull())
|
|
79
|
+
.addColumn('status', 'text', (col) => col.notNull().defaultTo('active'))
|
|
80
|
+
.addColumn('createdAt', 'timestamp', (col) => col.defaultTo(sql `CURRENT_TIMESTAMP`).notNull())
|
|
81
|
+
.addPrimaryKeyConstraint('workflow_versions_pk', [
|
|
82
|
+
'workflowName',
|
|
83
|
+
'graphHash',
|
|
84
|
+
]),
|
|
85
|
+
],
|
|
86
|
+
};
|