@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.
- package/CHANGELOG.md +106 -0
- package/LICENSE +21 -0
- package/README.md +29 -2
- package/dist/src/kysely-tables.d.ts +8 -0
- package/dist/src/kysely-workflow-mirror.js +37 -25
- package/dist/src/kysely-workflow-run-service.d.ts +0 -5
- package/dist/src/kysely-workflow-run-service.js +4 -16
- package/dist/src/kysely-workflow-service.d.ts +35 -6
- package/dist/src/kysely-workflow-service.js +194 -134
- package/dist/src/schema/index.d.ts +17 -0
- package/dist/src/schema/index.js +96 -9
- package/dist/src/schema/workflow.schema.d.ts +4 -2
- package/dist/src/schema/workflow.schema.js +28 -2
- package/package.json +3 -2
- package/src/kysely-tables.ts +8 -0
- package/src/kysely-workflow-mirror.test.ts +1 -13
- package/src/kysely-workflow-mirror.ts +44 -20
- package/src/kysely-workflow-run-service.ts +7 -20
- package/src/kysely-workflow-service.test.ts +431 -0
- package/src/kysely-workflow-service.ts +212 -161
- package/src/schema/index.ts +138 -13
- package/src/schema/schema.test.ts +183 -0
- package/src/schema/workflow.schema.ts +38 -2
|
@@ -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
|
|
12
|
-
*
|
|
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
|
}
|