@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 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
  /**
@@ -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
- await statement(bound, types).execute();
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 = /^\s*create table "([^"]+)"/i.exec(statement(bound, {}).compile().sql);
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) => new Set((await bind(db).introspection.getTables()).map((table) => table.name));
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.has(table));
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.filter((table) => existing.has(table)).join(', ')} already there. ` +
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.has(table)))
257
+ if (declared.every((table) => isPresent(after, table)))
171
258
  return 'present';
172
259
  throw error;
173
260
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/kysely",
3
- "version": "0.13.4",
3
+ "version": "0.13.5",
4
4
  "author": "yasser.fadl@gmail.com",
5
5
  "license": "MIT",
6
6
  "module": "dist/src/index.js",
@@ -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
- await statement(bound, types).execute()
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 = (schema: PikkuSchema, db: Kysely<any>): string[] => {
237
+ export const declaredTables = (
238
+ schema: PikkuSchema,
239
+ db: Kysely<any>
240
+ ): DeclaredTable[] => {
160
241
  const bound = bind(db)
161
- const tables: string[] = []
242
+ const tables: DeclaredTable[] = []
162
243
  for (const statement of schema.statements) {
163
- const match = /^\s*create table "([^"]+)"/i.exec(
164
- statement(bound, {}).compile().sql
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
- const tablesOf = async (db: Kysely<any>): Promise<Set<string>> =>
175
- new Set((await bind(db).introspection.getTables()).map((table) => table.name))
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.has(table))
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.filter((table) => existing.has(table)).join(', ')} already there. ` +
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.has(table))) return 'present'
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
+ })