@pikku/kysely 0.13.4 → 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 +25 -0
- package/dist/src/schema/index.d.ts +17 -0
- package/dist/src/schema/index.js +96 -9
- package/package.json +1 -1
- package/src/schema/index.ts +138 -13
- package/src/schema/schema.test.ts +183 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,28 @@
|
|
|
1
|
+
## 0.13.5
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- 220a8c4: fix: `ensurePikkuSchema` now sees tables on a `withSchema(...)`-bound connection
|
|
6
|
+
|
|
7
|
+
It read the table name out of its own compiled DDL with a regex that matched the
|
|
8
|
+
first quoted identifier. On a connection bound to a schema that DDL is
|
|
9
|
+
`create table "app"."workflow_runs"`, so it captured `app` — never a real table
|
|
10
|
+
name — concluded every table was missing, and issued a bare `create table` that
|
|
11
|
+
the database rejected with `relation "workflow_runs" already exists`, on every
|
|
12
|
+
boot.
|
|
13
|
+
|
|
14
|
+
The schema half of the name is now parsed, and the lookup matches on the pair
|
|
15
|
+
when the DDL is qualified (falling back to the bare name when it is not, since
|
|
16
|
+
an unqualified statement resolves against a `search_path` that is not knowable
|
|
17
|
+
from here). Error messages name the schema too.
|
|
18
|
+
|
|
19
|
+
Also: applying a schema over a `withSchema(...)`-bound **sqlite** connection now
|
|
20
|
+
explains itself. `withSchema` qualifies foreign key targets along with everything
|
|
21
|
+
else — which postgres requires and sqlite refuses, since a `REFERENCES` clause
|
|
22
|
+
there takes a bare table name — and all the engine says about it is
|
|
23
|
+
`near ".": syntax error`. The failure is now reported with the schema that
|
|
24
|
+
produced it and what to do instead, with the engine error kept as the `cause`.
|
|
25
|
+
|
|
1
26
|
## 0.13.4
|
|
2
27
|
|
|
3
28
|
### Patch Changes
|
|
@@ -51,6 +51,23 @@ export declare const unsatisfiedRequirements: (db: Kysely<any>, schemas?: PikkuS
|
|
|
51
51
|
* is the engine's to give, not ours.
|
|
52
52
|
*/
|
|
53
53
|
export declare const applyPikkuSchemas: (db: Kysely<any>, schemas?: PikkuSchema[]) => Promise<void>;
|
|
54
|
+
/** A table a schema creates, as the DDL addresses it. */
|
|
55
|
+
export interface DeclaredTable {
|
|
56
|
+
/** Present when the connection is `withSchema(...)`-bound. */
|
|
57
|
+
schema?: string;
|
|
58
|
+
name: string;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The tables a schema creates, read back out of its own compiled SQL.
|
|
62
|
+
*
|
|
63
|
+
* Derived rather than declared alongside the statements, because a hand-kept
|
|
64
|
+
* list of names is a second source of truth: rename a table in a statement and
|
|
65
|
+
* the list keeps answering for the old one.
|
|
66
|
+
*
|
|
67
|
+
* Compiled against `db` rather than in the abstract, so the answer carries the
|
|
68
|
+
* schema the DDL will actually target — the same binding the lookup has to use.
|
|
69
|
+
*/
|
|
70
|
+
export declare const declaredTables: (schema: PikkuSchema, db: Kysely<any>) => DeclaredTable[];
|
|
54
71
|
/** What `ensurePikkuSchema` found when it looked. */
|
|
55
72
|
export type EnsureOutcome = 'present' | 'created';
|
|
56
73
|
/**
|
package/dist/src/schema/index.js
CHANGED
|
@@ -103,29 +103,113 @@ export const applyPikkuSchemas = async (db, schemas = pikkuSchemas) => {
|
|
|
103
103
|
const bound = bind(trx);
|
|
104
104
|
for (const schema of schemas) {
|
|
105
105
|
for (const statement of schema.statements) {
|
|
106
|
-
|
|
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
|
+
}
|
|
107
113
|
}
|
|
108
114
|
}
|
|
109
115
|
});
|
|
110
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;
|
|
111
168
|
/**
|
|
112
169
|
* The tables a schema creates, read back out of its own compiled SQL.
|
|
113
170
|
*
|
|
114
171
|
* Derived rather than declared alongside the statements, because a hand-kept
|
|
115
172
|
* list of names is a second source of truth: rename a table in a statement and
|
|
116
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.
|
|
117
177
|
*/
|
|
118
|
-
const declaredTables = (schema, db) => {
|
|
178
|
+
export const declaredTables = (schema, db) => {
|
|
119
179
|
const bound = bind(db);
|
|
120
180
|
const tables = [];
|
|
121
181
|
for (const statement of schema.statements) {
|
|
122
|
-
const match =
|
|
182
|
+
const match = CREATE_TABLE.exec(statement(bound, {}).compile().sql);
|
|
123
183
|
if (match)
|
|
124
|
-
tables.push(match[1]);
|
|
184
|
+
tables.push({ schema: match[1], name: match[2] });
|
|
125
185
|
}
|
|
126
186
|
return tables;
|
|
127
187
|
};
|
|
128
|
-
const tablesOf = async (db) =>
|
|
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);
|
|
129
213
|
/**
|
|
130
214
|
* Make sure one schema's tables are there, creating them only if none are.
|
|
131
215
|
*
|
|
@@ -147,12 +231,15 @@ const tablesOf = async (db) => new Set((await bind(db).introspection.getTables()
|
|
|
147
231
|
export const ensurePikkuSchema = async (db, schema) => {
|
|
148
232
|
const existing = await tablesOf(db);
|
|
149
233
|
const declared = declaredTables(schema, db);
|
|
150
|
-
const missing = declared.filter((table) => !existing
|
|
234
|
+
const missing = declared.filter((table) => !isPresent(existing, table));
|
|
151
235
|
if (missing.length === 0)
|
|
152
236
|
return 'present';
|
|
153
237
|
if (missing.length < declared.length) {
|
|
154
|
-
throw new Error(`The '${schema.name}' schema is half applied: ${missing.join(', ')} missing, ` +
|
|
155
|
-
`${declared
|
|
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. ` +
|
|
156
243
|
'Something else owns part of it. Reconcile it with a migration — ' +
|
|
157
244
|
'`pikku db generate` writes the declaration down — rather than creating the rest at boot.');
|
|
158
245
|
}
|
|
@@ -167,7 +254,7 @@ export const ensurePikkuSchema = async (db, schema) => {
|
|
|
167
254
|
// a guarantee — a boot race is only fully answered by having run the
|
|
168
255
|
// migration, which is what `pikku db generate` is for.
|
|
169
256
|
const after = await tablesOf(db);
|
|
170
|
-
if (declared.every((table) => after
|
|
257
|
+
if (declared.every((table) => isPresent(after, table)))
|
|
171
258
|
return 'present';
|
|
172
259
|
throw error;
|
|
173
260
|
}
|
package/package.json
CHANGED
package/src/schema/index.ts
CHANGED
|
@@ -143,27 +143,106 @@ export const applyPikkuSchemas = async (
|
|
|
143
143
|
const bound = bind(trx)
|
|
144
144
|
for (const schema of schemas) {
|
|
145
145
|
for (const statement of schema.statements) {
|
|
146
|
-
|
|
146
|
+
const query = statement(bound, types)
|
|
147
|
+
try {
|
|
148
|
+
await query.execute()
|
|
149
|
+
} catch (error) {
|
|
150
|
+
throw explain(error, query.compile().sql, schema)
|
|
151
|
+
}
|
|
147
152
|
}
|
|
148
153
|
}
|
|
149
154
|
})
|
|
150
155
|
}
|
|
151
156
|
|
|
157
|
+
/** `references "app"."workflow_runs"` — a foreign key onto a qualified table. */
|
|
158
|
+
const QUALIFIED_REFERENCE = /references "[^"]+"\."[^"]+"/i
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* sqlite's own words for "you put a dot where a table name goes".
|
|
162
|
+
*
|
|
163
|
+
* Matched rather than a driver's `code`, because every sqlite binding
|
|
164
|
+
* (better-sqlite3, node:sqlite, bun:sqlite, libsql) passes the engine's message
|
|
165
|
+
* through verbatim while disagreeing about how to label it. Postgres phrases the
|
|
166
|
+
* same class of error the other way round — `syntax error at or near "."` — so
|
|
167
|
+
* this cannot fire on it.
|
|
168
|
+
*/
|
|
169
|
+
const SQLITE_REJECTED_QUALIFIER = /near "\.":\s*syntax error/i
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Say what a schema-bound connection did to the DDL, when that is what failed.
|
|
173
|
+
*
|
|
174
|
+
* `withSchema(...)` qualifies foreign key targets along with everything else.
|
|
175
|
+
* Postgres needs that — an unqualified reference resolves against `search_path`,
|
|
176
|
+
* which a schema-bound connection does not control — and sqlite refuses it: a
|
|
177
|
+
* `REFERENCES` clause there takes a bare table name, because a foreign key can
|
|
178
|
+
* only point inside the same database, so there is no qualifier to give. One
|
|
179
|
+
* declaration cannot spell both, and what the engine says about it is
|
|
180
|
+
* `near ".": syntax error`, which names neither the schema nor the cause.
|
|
181
|
+
*
|
|
182
|
+
* Gated on sqlite's exact phrasing as well as the SQL. Postgres compiles the
|
|
183
|
+
* same qualified reference legitimately, and can raise a syntax error of its own
|
|
184
|
+
* on this DDL — a column type resolved from `requires` goes in verbatim — so a
|
|
185
|
+
* looser `/syntax error/` would answer for postgres in sqlite's name.
|
|
186
|
+
*/
|
|
187
|
+
const explain = (error: unknown, sql: string, schema: PikkuSchema): unknown => {
|
|
188
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
189
|
+
if (
|
|
190
|
+
!QUALIFIED_REFERENCE.test(sql) ||
|
|
191
|
+
!SQLITE_REJECTED_QUALIFIER.test(message)
|
|
192
|
+
) {
|
|
193
|
+
return error
|
|
194
|
+
}
|
|
195
|
+
return new Error(
|
|
196
|
+
`The '${schema.name}' schema has a foreign key, and this connection is bound to a schema, ` +
|
|
197
|
+
`so it compiled to a qualified 'references "schema"."table"' that the database rejected. ` +
|
|
198
|
+
'sqlite is the engine that does this: its REFERENCES clause takes a bare table name. ' +
|
|
199
|
+
'Use a connection without withSchema(...) there — sqlite has one schema to be in — ' +
|
|
200
|
+
'or write the schema down as a migration with `pikku db generate` and apply it yourself. ' +
|
|
201
|
+
`The database said: ${message}`,
|
|
202
|
+
{ cause: error }
|
|
203
|
+
)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** A table a schema creates, as the DDL addresses it. */
|
|
207
|
+
export interface DeclaredTable {
|
|
208
|
+
/** Present when the connection is `withSchema(...)`-bound. */
|
|
209
|
+
schema?: string
|
|
210
|
+
name: string
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** How a table reads in an error message: qualified only when it is. */
|
|
214
|
+
const displayName = (table: DeclaredTable): string =>
|
|
215
|
+
table.schema ? `${table.schema}.${table.name}` : table.name
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* `create table "workflow_runs"` and `create table "app"."workflow_runs"`.
|
|
219
|
+
*
|
|
220
|
+
* The schema half is optional and the table is always the LAST quoted
|
|
221
|
+
* identifier — matching the first one instead reads a `withSchema('app')`
|
|
222
|
+
* connection's DDL as a table called `app`, which is no table at all.
|
|
223
|
+
*/
|
|
224
|
+
const CREATE_TABLE =
|
|
225
|
+
/^\s*create table (?:if not exists\s+)?(?:"([^"]+)"\.)?"([^"]+)"/i
|
|
226
|
+
|
|
152
227
|
/**
|
|
153
228
|
* The tables a schema creates, read back out of its own compiled SQL.
|
|
154
229
|
*
|
|
155
230
|
* Derived rather than declared alongside the statements, because a hand-kept
|
|
156
231
|
* list of names is a second source of truth: rename a table in a statement and
|
|
157
232
|
* the list keeps answering for the old one.
|
|
233
|
+
*
|
|
234
|
+
* Compiled against `db` rather than in the abstract, so the answer carries the
|
|
235
|
+
* schema the DDL will actually target — the same binding the lookup has to use.
|
|
158
236
|
*/
|
|
159
|
-
const declaredTables = (
|
|
237
|
+
export const declaredTables = (
|
|
238
|
+
schema: PikkuSchema,
|
|
239
|
+
db: Kysely<any>
|
|
240
|
+
): DeclaredTable[] => {
|
|
160
241
|
const bound = bind(db)
|
|
161
|
-
const tables:
|
|
242
|
+
const tables: DeclaredTable[] = []
|
|
162
243
|
for (const statement of schema.statements) {
|
|
163
|
-
const match =
|
|
164
|
-
|
|
165
|
-
)
|
|
166
|
-
if (match) tables.push(match[1]!)
|
|
244
|
+
const match = CREATE_TABLE.exec(statement(bound, {}).compile().sql)
|
|
245
|
+
if (match) tables.push({ schema: match[1], name: match[2]! })
|
|
167
246
|
}
|
|
168
247
|
return tables
|
|
169
248
|
}
|
|
@@ -171,8 +250,51 @@ const declaredTables = (schema: PikkuSchema, db: Kysely<any>): string[] => {
|
|
|
171
250
|
/** What `ensurePikkuSchema` found when it looked. */
|
|
172
251
|
export type EnsureOutcome = 'present' | 'created'
|
|
173
252
|
|
|
174
|
-
|
|
175
|
-
|
|
253
|
+
/**
|
|
254
|
+
* What `db` already has, indexed both ways.
|
|
255
|
+
*
|
|
256
|
+
* Introspection reports every schema it can see, so the bare name alone cannot
|
|
257
|
+
* answer the question: a `workflow_runs` in some other schema is not the one a
|
|
258
|
+
* `withSchema('app')` connection reads or writes. Qualified declarations are
|
|
259
|
+
* matched on the pair. Unqualified DDL resolves against the connection's
|
|
260
|
+
* search_path, which is not knowable from here, so it falls back to the name.
|
|
261
|
+
*/
|
|
262
|
+
interface ExistingTables {
|
|
263
|
+
qualified: Set<string>
|
|
264
|
+
bare: Set<string>
|
|
265
|
+
/** Whether this engine reports schemas at all. */
|
|
266
|
+
schemaAware: boolean
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
const tablesOf = async (db: Kysely<any>): Promise<ExistingTables> => {
|
|
270
|
+
const tables = await bind(db).introspection.getTables()
|
|
271
|
+
return {
|
|
272
|
+
qualified: new Set(
|
|
273
|
+
tables
|
|
274
|
+
.filter((table) => table.schema)
|
|
275
|
+
.map((table) => `${table.schema}.${table.name}`)
|
|
276
|
+
),
|
|
277
|
+
bare: new Set(tables.map((table) => table.name)),
|
|
278
|
+
schemaAware: tables.some((table) => table.schema !== undefined),
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Qualified declarations match on the pair — but only where that pair means
|
|
284
|
+
* something.
|
|
285
|
+
*
|
|
286
|
+
* `TableMetadata.schema` is optional, and sqlite and mysql leave it unset:
|
|
287
|
+
* sqlite has one schema to be in, and mysql calls the thing a database. A
|
|
288
|
+
* `withSchema('main')` connection there declares `main.workflow_runs` while
|
|
289
|
+
* introspection can only offer `workflow_runs`, and holding out for the pair
|
|
290
|
+
* would read every existing table as missing — the same false negative this
|
|
291
|
+
* whole function exists to fix, arrived at from the other side. So when the
|
|
292
|
+
* engine reports no schemas, the bare name is the only name there is.
|
|
293
|
+
*/
|
|
294
|
+
const isPresent = (existing: ExistingTables, table: DeclaredTable): boolean =>
|
|
295
|
+
table.schema && existing.schemaAware
|
|
296
|
+
? existing.qualified.has(`${table.schema}.${table.name}`)
|
|
297
|
+
: existing.bare.has(table.name)
|
|
176
298
|
|
|
177
299
|
/**
|
|
178
300
|
* Make sure one schema's tables are there, creating them only if none are.
|
|
@@ -198,14 +320,17 @@ export const ensurePikkuSchema = async (
|
|
|
198
320
|
): Promise<EnsureOutcome> => {
|
|
199
321
|
const existing = await tablesOf(db)
|
|
200
322
|
const declared = declaredTables(schema, db)
|
|
201
|
-
const missing = declared.filter((table) => !existing
|
|
323
|
+
const missing = declared.filter((table) => !isPresent(existing, table))
|
|
202
324
|
|
|
203
325
|
if (missing.length === 0) return 'present'
|
|
204
326
|
|
|
205
327
|
if (missing.length < declared.length) {
|
|
206
328
|
throw new Error(
|
|
207
|
-
`The '${schema.name}' schema is half applied: ${missing.join(', ')} missing, ` +
|
|
208
|
-
`${declared
|
|
329
|
+
`The '${schema.name}' schema is half applied: ${missing.map(displayName).join(', ')} missing, ` +
|
|
330
|
+
`${declared
|
|
331
|
+
.filter((table) => isPresent(existing, table))
|
|
332
|
+
.map(displayName)
|
|
333
|
+
.join(', ')} already there. ` +
|
|
209
334
|
'Something else owns part of it. Reconcile it with a migration — ' +
|
|
210
335
|
'`pikku db generate` writes the declaration down — rather than creating the rest at boot.'
|
|
211
336
|
)
|
|
@@ -221,7 +346,7 @@ export const ensurePikkuSchema = async (
|
|
|
221
346
|
// a guarantee — a boot race is only fully answered by having run the
|
|
222
347
|
// migration, which is what `pikku db generate` is for.
|
|
223
348
|
const after = await tablesOf(db)
|
|
224
|
-
if (declared.every((table) => after
|
|
349
|
+
if (declared.every((table) => isPresent(after, table))) return 'present'
|
|
225
350
|
throw error
|
|
226
351
|
}
|
|
227
352
|
}
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
compilePikkuSchemas,
|
|
18
18
|
applyPikkuSchemas,
|
|
19
19
|
ensurePikkuSchema,
|
|
20
|
+
declaredTables,
|
|
20
21
|
unsatisfiedRequirements,
|
|
21
22
|
type PikkuSchema,
|
|
22
23
|
} from './index.js'
|
|
@@ -37,6 +38,21 @@ const dialect = (kind: 'postgres' | 'sqlite') =>
|
|
|
37
38
|
createQueryCompiler: () => new SqliteQueryCompiler(),
|
|
38
39
|
}
|
|
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
|
+
|
|
40
56
|
const compileFor = (kind: 'postgres' | 'sqlite') =>
|
|
41
57
|
compilePikkuSchemas(new Kysely<any>({ dialect: dialect(kind) }))
|
|
42
58
|
|
|
@@ -271,3 +287,170 @@ describe('ensurePikkuSchema — what a service does at boot', () => {
|
|
|
271
287
|
}
|
|
272
288
|
})
|
|
273
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
|
+
})
|