@pikku/kysely 0.13.4 → 0.13.6

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.
@@ -10,7 +10,7 @@ import {
10
10
  type WorkflowStatus,
11
11
  type WorkflowVersionStatus,
12
12
  } from '@pikku/core/workflow'
13
- import type { Kysely } from 'kysely'
13
+ import { sql, type Kysely } from 'kysely'
14
14
  import type { KyselyPikkuDB } from './kysely-tables.js'
15
15
  import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js'
16
16
  import { parseJson } from './kysely-json.js'
@@ -112,12 +112,13 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
112
112
  retries: stepOptions?.retries ?? null,
113
113
  retryDelay: stepOptions?.retryDelay?.toString() ?? null,
114
114
  fromStepName: fromStepName ?? null,
115
+ currentAttempt: 1,
115
116
  createdAt: now,
116
117
  updatedAt: now,
117
118
  })
118
119
  .execute()
119
120
 
120
- await this.insertHistoryRecord(stepId, 'pending')
121
+ await this.insertHistoryRecord(stepId, 'pending', 1)
121
122
 
122
123
  return {
123
124
  stepId,
@@ -135,31 +136,24 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
135
136
 
136
137
  async getStepState(runId: string, stepName: string): Promise<StepState> {
137
138
  const row = await this.db
138
- .selectFrom('workflowStep as s')
139
+ .selectFrom('workflowStep')
139
140
  .select([
140
- 's.workflowStepId',
141
- 's.status',
142
- 's.result',
143
- 's.error',
144
- 's.retries',
145
- 's.retryDelay',
146
- 's.fromStepName',
147
- 's.createdAt',
148
- 's.updatedAt',
141
+ 'workflowStepId',
142
+ 'status',
143
+ 'result',
144
+ 'error',
145
+ 'retries',
146
+ 'retryDelay',
147
+ 'fromStepName',
148
+ // `current_attempt` is bumped alongside every history insert, so it is
149
+ // already the count this used to aggregate — and reading it keeps the
150
+ // engine's hottest query off a table that grows for the life of the run.
151
+ 'currentAttempt',
152
+ 'createdAt',
153
+ 'updatedAt',
149
154
  ])
150
- .select((eb) =>
151
- eb
152
- .selectFrom('workflowStepHistory')
153
- .select(eb.fn.countAll<number>().as('cnt'))
154
- .whereRef(
155
- 'workflowStepHistory.workflowStepId',
156
- '=',
157
- 's.workflowStepId'
158
- )
159
- .as('attemptCount')
160
- )
161
- .where('s.workflowRunId', '=', runId)
162
- .where('s.stepName', '=', stepName)
155
+ .where('workflowRunId', '=', runId)
156
+ .where('stepName', '=', stepName)
163
157
  .executeTakeFirst()
164
158
 
165
159
  if (!row) {
@@ -173,7 +167,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
173
167
  status: row.status as StepState['status'],
174
168
  result: parseJson(row.result),
175
169
  error: parseJson(row.error),
176
- attemptCount: Number(row.attemptCount),
170
+ attemptCount: Number(row.currentAttempt ?? 1),
177
171
  retries: row.retries != null ? Number(row.retries) : undefined,
178
172
  retryDelay: row.retryDelay ?? undefined,
179
173
  fromStepName: row.fromStepName ?? undefined,
@@ -182,6 +176,42 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
182
176
  }
183
177
  }
184
178
 
179
+ protected override async listStepStates(
180
+ runId: string
181
+ ): Promise<Array<StepState & { stepName: string }>> {
182
+ const rows = await this.db
183
+ .selectFrom('workflowStep')
184
+ .select([
185
+ 'workflowStepId',
186
+ 'stepName',
187
+ 'status',
188
+ 'result',
189
+ 'error',
190
+ 'retries',
191
+ 'retryDelay',
192
+ 'fromStepName',
193
+ 'currentAttempt',
194
+ 'createdAt',
195
+ 'updatedAt',
196
+ ])
197
+ .where('workflowRunId', '=', runId)
198
+ .execute()
199
+
200
+ return rows.map((row) => ({
201
+ stepId: row.workflowStepId,
202
+ stepName: row.stepName,
203
+ status: row.status as StepState['status'],
204
+ result: parseJson(row.result),
205
+ error: parseJson(row.error),
206
+ attemptCount: Number(row.currentAttempt ?? 1),
207
+ retries: row.retries != null ? Number(row.retries) : undefined,
208
+ retryDelay: row.retryDelay ?? undefined,
209
+ fromStepName: row.fromStepName ?? undefined,
210
+ createdAt: new Date(row.createdAt),
211
+ updatedAt: new Date(row.updatedAt),
212
+ }))
213
+ }
214
+
185
215
  async getRunHistory(
186
216
  runId: string
187
217
  ): Promise<Array<StepState & { stepName: string }>> {
@@ -189,27 +219,9 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
189
219
  }
190
220
 
191
221
  protected async setStepRunningImpl(stepId: string): Promise<void> {
192
- await this.db
193
- .updateTable('workflowStep')
194
- .set({ status: 'running', updatedAt: new Date() })
195
- .where('workflowStepId', '=', stepId)
196
- .execute()
197
-
198
- const latestHistory = await this.db
199
- .selectFrom('workflowStepHistory')
200
- .select('historyId')
201
- .where('workflowStepId', '=', stepId)
202
- .orderBy('createdAt', 'desc')
203
- .limit(1)
204
- .executeTakeFirst()
205
-
206
- if (latestHistory) {
207
- await this.db
208
- .updateTable('workflowStepHistory')
209
- .set({ status: 'running', runningAt: new Date() })
210
- .where('historyId', '=', latestHistory.historyId)
211
- .execute()
212
- }
222
+ await this.writeStepTransition(stepId, 'running', {
223
+ runningAt: new Date(),
224
+ })
213
225
  }
214
226
 
215
227
  protected async setStepScheduledImpl(stepId: string): Promise<void> {
@@ -220,9 +232,88 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
220
232
  .execute()
221
233
  }
222
234
 
235
+ /**
236
+ * Move a step and its current history attempt to the same status in one
237
+ * transaction.
238
+ *
239
+ * Both halves matter: a crash between them used to leave the step row saying
240
+ * `succeeded` while its history row still said `running`, which silently
241
+ * corrupts `getRunHistory` and every timeline reconstruction built on it.
242
+ * The history row is targeted by its `attempt` rather than by a `LIMIT 1`
243
+ * over `created_at`, so a retry that lands in the same millisecond as the
244
+ * attempt it replaces still resolves the newer row.
245
+ */
246
+ private async writeStepTransition(
247
+ stepId: string,
248
+ status: StepStatus,
249
+ historyValues: Record<string, unknown>
250
+ ): Promise<void> {
251
+ const now = new Date()
252
+ const stepValues: Record<string, unknown> = { status, updatedAt: now }
253
+ if ('result' in historyValues) stepValues.result = historyValues.result
254
+ if ('error' in historyValues) stepValues.error = historyValues.error
255
+ // A step carries only its latest outcome, so the other half is cleared.
256
+ if (status === 'succeeded') stepValues.error = null
257
+ if (status === 'failed') stepValues.result = null
258
+
259
+ await this.db.transaction().execute(async (trx) => {
260
+ await trx
261
+ .updateTable('workflowStep')
262
+ .set({
263
+ ...stepValues,
264
+ // A step that somehow reaches a transition without a live attempt
265
+ // addresses no history row at all, so give it one here. Written as an
266
+ // expression over the row's own column rather than a subquery, which
267
+ // MySQL would reject against the table being updated.
268
+ currentAttempt: sql`coalesce(current_attempt, 1)`,
269
+ } as any)
270
+ .where('workflowStepId', '=', stepId)
271
+ .execute()
272
+
273
+ const written = await trx
274
+ .updateTable('workflowStepHistory')
275
+ .set({ status, ...historyValues } as any)
276
+ .where('workflowStepId', '=', stepId)
277
+ // Correlate through the step row rather than a MAX() over this table:
278
+ // MySQL rejects a subquery that reads the table being updated, and a
279
+ // primary-key lookup is cheaper than an aggregate anyway.
280
+ .where('attempt', '=', (eb) =>
281
+ eb
282
+ .selectFrom('workflowStep')
283
+ .select('currentAttempt')
284
+ .where('workflowStepId', '=', stepId)
285
+ )
286
+ .executeTakeFirst()
287
+
288
+ // Matching nothing means the attempt has no history row to move, which
289
+ // would commit the step's new status with no record of it — precisely
290
+ // the divergence this transaction exists to prevent. Write the missing
291
+ // row instead of letting the pair drift apart.
292
+ if (Number(written?.numUpdatedRows ?? 0n) === 0) {
293
+ const step = await trx
294
+ .selectFrom('workflowStep')
295
+ .select('currentAttempt')
296
+ .where('workflowStepId', '=', stepId)
297
+ .executeTakeFirst()
298
+ await trx
299
+ .insertInto('workflowStepHistory')
300
+ .values({
301
+ historyId: crypto.randomUUID(),
302
+ workflowStepId: stepId,
303
+ status,
304
+ attempt: step?.currentAttempt ?? 1,
305
+ createdAt: now,
306
+ ...historyValues,
307
+ } as any)
308
+ .execute()
309
+ }
310
+ })
311
+ }
312
+
223
313
  private async insertHistoryRecord(
224
314
  stepId: string,
225
315
  status: string,
316
+ attempt: number,
226
317
  result?: any,
227
318
  error?: SerializedError
228
319
  ): Promise<void> {
@@ -231,6 +322,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
231
322
  historyId: crypto.randomUUID(),
232
323
  workflowStepId: stepId,
233
324
  status,
325
+ attempt,
234
326
  result: result != null ? JSON.stringify(result) : null,
235
327
  error: error != null ? JSON.stringify(error) : null,
236
328
  createdAt: now,
@@ -280,38 +372,10 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
280
372
  stepId: string,
281
373
  result: any
282
374
  ): Promise<void> {
283
- const resultJson = JSON.stringify(result)
284
-
285
- await this.db
286
- .updateTable('workflowStep')
287
- .set({
288
- status: 'succeeded',
289
- result: resultJson,
290
- error: null,
291
- updatedAt: new Date(),
292
- })
293
- .where('workflowStepId', '=', stepId)
294
- .execute()
295
-
296
- const latestHistory = await this.db
297
- .selectFrom('workflowStepHistory')
298
- .select('historyId')
299
- .where('workflowStepId', '=', stepId)
300
- .orderBy('createdAt', 'desc')
301
- .limit(1)
302
- .executeTakeFirst()
303
-
304
- if (latestHistory) {
305
- await this.db
306
- .updateTable('workflowStepHistory')
307
- .set({
308
- status: 'succeeded',
309
- result: resultJson,
310
- succeededAt: new Date(),
311
- })
312
- .where('historyId', '=', latestHistory.historyId)
313
- .execute()
314
- }
375
+ await this.writeStepTransition(stepId, 'succeeded', {
376
+ result: JSON.stringify(result),
377
+ succeededAt: new Date(),
378
+ })
315
379
  }
316
380
 
317
381
  protected async setStepErrorImpl(
@@ -323,73 +387,54 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
323
387
  stack: error.stack,
324
388
  code: (error as any).code,
325
389
  }
326
- const errorJson = JSON.stringify(serializedError)
327
-
328
- await this.db
329
- .updateTable('workflowStep')
330
- .set({
331
- status: 'failed',
332
- error: errorJson,
333
- result: null,
334
- updatedAt: new Date(),
335
- })
336
- .where('workflowStepId', '=', stepId)
337
- .execute()
338
-
339
- const latestHistory = await this.db
340
- .selectFrom('workflowStepHistory')
341
- .select('historyId')
342
- .where('workflowStepId', '=', stepId)
343
- .orderBy('createdAt', 'desc')
344
- .limit(1)
345
- .executeTakeFirst()
346
-
347
- if (latestHistory) {
348
- await this.db
349
- .updateTable('workflowStepHistory')
350
- .set({ status: 'failed', error: errorJson, failedAt: new Date() })
351
- .where('historyId', '=', latestHistory.historyId)
352
- .execute()
353
- }
390
+ await this.writeStepTransition(stepId, 'failed', {
391
+ error: JSON.stringify(serializedError),
392
+ failedAt: new Date(),
393
+ })
354
394
  }
355
395
 
356
396
  protected async createRetryAttemptImpl(
357
397
  stepId: string,
358
398
  status: 'pending' | 'running'
359
399
  ): Promise<StepState> {
400
+ // Read before writing: the new attempt number comes from the history this
401
+ // step already has, and the row it reads is the one returned below.
402
+ const previous = await this.db
403
+ .selectFrom('workflowStep')
404
+ .select('currentAttempt')
405
+ .where('workflowStepId', '=', stepId)
406
+ .executeTakeFirst()
407
+ const attempt = (previous?.currentAttempt ?? 0) + 1
408
+
360
409
  await this.db
361
410
  .updateTable('workflowStep')
362
- .set({ status, result: null, error: null, updatedAt: new Date() })
411
+ .set({
412
+ status,
413
+ result: null,
414
+ error: null,
415
+ currentAttempt: attempt,
416
+ updatedAt: new Date(),
417
+ })
363
418
  .where('workflowStepId', '=', stepId)
364
419
  .execute()
365
420
 
366
- await this.insertHistoryRecord(stepId, status)
421
+ await this.insertHistoryRecord(stepId, status, attempt)
367
422
 
368
423
  const row = await this.db
369
- .selectFrom('workflowStep as s')
424
+ .selectFrom('workflowStep')
370
425
  .select([
371
- 's.workflowStepId',
372
- 's.status',
373
- 's.result',
374
- 's.error',
375
- 's.retries',
376
- 's.retryDelay',
377
- 's.fromStepName',
378
- 's.createdAt',
379
- 's.updatedAt',
426
+ 'workflowStepId',
427
+ 'status',
428
+ 'result',
429
+ 'error',
430
+ 'retries',
431
+ 'retryDelay',
432
+ 'fromStepName',
433
+ 'currentAttempt',
434
+ 'createdAt',
435
+ 'updatedAt',
380
436
  ])
381
- .select((eb) =>
382
- eb
383
- .selectFrom('workflowStepHistory')
384
- .select(eb.fn.countAll<number>().as('cnt'))
385
- .whereRef(
386
- 'workflowStepHistory.workflowStepId',
387
- '=',
388
- 's.workflowStepId'
389
- )
390
- .as('attemptCount')
391
- )
392
- .where('s.workflowStepId', '=', stepId)
437
+ .where('workflowStepId', '=', stepId)
393
438
  .executeTakeFirstOrThrow()
394
439
 
395
440
  return {
@@ -397,7 +442,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
397
442
  status: row.status as StepState['status'],
398
443
  result: parseJson(row.result),
399
444
  error: parseJson(row.error),
400
- attemptCount: Number(row.attemptCount),
445
+ attemptCount: Number(row.currentAttempt ?? 1),
401
446
  retries: row.retries != null ? Number(row.retries) : undefined,
402
447
  retryDelay: row.retryDelay ?? undefined,
403
448
  fromStepName: row.fromStepName ?? undefined,
@@ -526,23 +571,48 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
526
571
  .execute()
527
572
  }
528
573
 
574
+ /**
575
+ * Merge one key into the run's JSON state, as a single expression evaluated
576
+ * by the database.
577
+ *
578
+ * The SQL is dialect-specific, so subclasses override this rather than the
579
+ * caller. The default is the SQLite form; MySQL and Postgres differ in how
580
+ * they cast a JSON literal.
581
+ *
582
+ * @param path - JSON path to the key, already quoted (`$."key"`)
583
+ * @param json - The value as JSON text
584
+ */
585
+ protected jsonSetState(path: string, json: string) {
586
+ return sql<string>`json_set(coalesce(state, '{}'), ${path}, json(${json}))`
587
+ }
588
+
589
+ /**
590
+ * A JSON path for an arbitrary key. State keys are user-supplied (a graph's
591
+ * `setState` name, an approval's hex-encoded reason), so the key is quoted
592
+ * rather than interpolated bare — an unquoted `$.a.b` would silently address
593
+ * a nested path instead of the key literally called `a.b`.
594
+ */
595
+ private jsonPathFor(name: string): string {
596
+ return `$.${JSON.stringify(name)}`
597
+ }
598
+
529
599
  protected async updateRunStateImpl(
530
600
  runId: string,
531
601
  name: string,
532
602
  value: unknown
533
603
  ): Promise<void> {
534
- const row = await this.db
535
- .selectFrom('workflowRuns')
536
- .select('state')
537
- .where('workflowRunId', '=', runId)
538
- .executeTakeFirst()
539
-
540
- const state: Record<string, unknown> = parseJson(row?.state) ?? {}
541
- state[name] = value
542
-
604
+ // Merged by the database, not read-modify-written in JS: two graph nodes
605
+ // calling setState concurrently used to race, and the later write silently
606
+ // dropped whichever key the earlier one had added.
543
607
  await this.db
544
608
  .updateTable('workflowRuns')
545
- .set({ state: JSON.stringify(state), updatedAt: new Date() })
609
+ .set({
610
+ state: this.jsonSetState(
611
+ this.jsonPathFor(name),
612
+ JSON.stringify(value ?? null)
613
+ ),
614
+ updatedAt: new Date(),
615
+ })
546
616
  .where('workflowRunId', '=', runId)
547
617
  .execute()
548
618
  }
@@ -598,24 +668,5 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
598
668
  return this.runService.getWorkflowVersion(name, graphHash)
599
669
  }
600
670
 
601
- async getAIGeneratedWorkflows(
602
- agentName?: string
603
- ): Promise<Array<{ workflowName: string; graphHash: string; graph: any }>> {
604
- let query = this.db
605
- .selectFrom('workflowVersions')
606
- .select(['workflowName', 'graphHash', 'graph'])
607
- .where('source', '=', 'dynamic-workflow')
608
- .where('status', '=', 'active')
609
- if (agentName) {
610
- query = query.where('workflowName', 'like', `ai:${agentName}:%`)
611
- }
612
- const rows = await query.execute()
613
- return rows.map((row) => ({
614
- workflowName: row.workflowName,
615
- graphHash: row.graphHash,
616
- graph: typeof row.graph === 'string' ? JSON.parse(row.graph) : row.graph,
617
- }))
618
- }
619
-
620
671
  async close(): Promise<void> {}
621
672
  }
@@ -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
  }