@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.
@@ -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
+ })
@@ -8,8 +8,10 @@ import type { PikkuSchema } from './pikku-schema.types.js'
8
8
  * primary keys had `defaultTo(sql.raw("'" + crypto.randomUUID() + "'"))`, which
9
9
  * evaluates once while the statement is built — every row would have taken the
10
10
  * same default, so it was never a generator; the services supply the id. And
11
- * `workflowStep.fromStepName` was backfilled by an `alterTable(...).catch(() =>
12
- * {})` bolted on after the fact, which is the job a migration does.
11
+ * `workflowStep.fromStepName`, `workflowStep.currentAttempt` and
12
+ * `workflowStepHistory.attempt` were each backfilled by an
13
+ * `alterTable(...).catch(() => {})` bolted on after the fact, which is the job a
14
+ * migration does — they are declared here and nowhere else.
13
15
  *
14
16
  * `KyselyWorkflowMirror` created these same four tables separately. There is one
15
17
  * declaration now, and both services read it.
@@ -62,6 +64,7 @@ export const workflowSchema: PikkuSchema = {
62
64
  .addColumn('retries', 'integer')
63
65
  .addColumn('retryDelay', 'text')
64
66
  .addColumn('fromStepName', 'text')
67
+ .addColumn('currentAttempt', 'integer')
65
68
  .addColumn('createdAt', 'timestamp', (col) =>
66
69
  col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
67
70
  )
@@ -86,6 +89,7 @@ export const workflowSchema: PikkuSchema = {
86
89
  .addColumn('status', 'text', (col) => col.notNull())
87
90
  .addColumn('result', 'text')
88
91
  .addColumn('error', 'text')
92
+ .addColumn('attempt', 'integer')
89
93
  .addColumn('createdAt', 'timestamp', (col) =>
90
94
  col.defaultTo(sql`CURRENT_TIMESTAMP`).notNull()
91
95
  )
@@ -109,5 +113,37 @@ export const workflowSchema: PikkuSchema = {
109
113
  'workflowName',
110
114
  'graphHash',
111
115
  ]),
116
+
117
+ // The indexes trail the tables so that a table is never indexed before it
118
+ // exists, whatever order the declarations are read in.
119
+ (db) =>
120
+ db.schema
121
+ .createIndex('idx_workflow_runs_status_created')
122
+ .on('workflowRuns')
123
+ .columns(['status', 'createdAt']),
124
+
125
+ (db) =>
126
+ db.schema
127
+ .createIndex('idx_workflow_runs_workflow_created')
128
+ .on('workflowRuns')
129
+ .columns(['workflow', 'createdAt']),
130
+
131
+ (db) =>
132
+ db.schema
133
+ .createIndex('idx_workflow_step_run_status')
134
+ .on('workflowStep')
135
+ .columns(['workflowRunId', 'status']),
136
+
137
+ (db) =>
138
+ db.schema
139
+ .createIndex('idx_workflow_step_history_step')
140
+ .on('workflowStepHistory')
141
+ .columns(['workflowStepId', 'createdAt']),
142
+
143
+ (db) =>
144
+ db.schema
145
+ .createIndex('idx_workflow_versions_source_status')
146
+ .on('workflowVersions')
147
+ .columns(['source', 'status']),
112
148
  ],
113
149
  }