@pikku/kysely 0.13.18 → 0.13.20

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.
Files changed (66) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/dist/src/index.d.ts +1 -1
  3. package/dist/src/index.js +1 -1
  4. package/dist/src/kysely-agent-run-state-service.js +2 -2
  5. package/dist/src/kysely-agent-storage-service.js +2 -2
  6. package/dist/src/kysely-audit-service.js +2 -2
  7. package/dist/src/kysely-channel-store.js +2 -2
  8. package/dist/src/kysely-credential-service.js +2 -2
  9. package/dist/src/kysely-deployment-service.js +2 -2
  10. package/dist/src/kysely-scope-service.js +2 -2
  11. package/dist/src/kysely-secret-service.js +2 -2
  12. package/dist/src/kysely-session-store.js +2 -2
  13. package/dist/src/kysely-tables.d.ts +28 -0
  14. package/dist/src/kysely-virtual-user-run-store.js +2 -2
  15. package/dist/src/kysely-webhook-service.js +2 -2
  16. package/dist/src/kysely-workflow-mirror.d.ts +1 -1
  17. package/dist/src/kysely-workflow-mirror.js +3 -3
  18. package/dist/src/kysely-workflow-service.d.ts +39 -8
  19. package/dist/src/kysely-workflow-service.js +77 -11
  20. package/dist/src/schema/agent.schema.js +1 -0
  21. package/dist/src/schema/audit.schema.d.ts +5 -4
  22. package/dist/src/schema/audit.schema.js +6 -4
  23. package/dist/src/schema/channel.schema.js +1 -0
  24. package/dist/src/schema/credential.schema.js +1 -0
  25. package/dist/src/schema/index.d.ts +47 -26
  26. package/dist/src/schema/index.js +56 -46
  27. package/dist/src/schema/pikku-schema.types.d.ts +20 -0
  28. package/dist/src/schema/scope.schema.js +1 -0
  29. package/dist/src/schema/virtual-user.schema.js +2 -1
  30. package/dist/src/schema/webhook.schema.js +1 -0
  31. package/dist/src/schema/workflow.schema.js +1 -0
  32. package/package.json +2 -2
  33. package/src/index.ts +12 -2
  34. package/src/kysely-agent-run-state-service.ts +2 -2
  35. package/src/kysely-agent-storage-service.ts +2 -2
  36. package/src/kysely-audit-service.test.ts +2 -10
  37. package/src/kysely-audit-service.ts +2 -2
  38. package/src/kysely-channel-store.ts +2 -2
  39. package/src/kysely-credential-service.ts +2 -2
  40. package/src/kysely-deployment-service.ts +2 -2
  41. package/src/kysely-scope-service.test.ts +2 -0
  42. package/src/kysely-scope-service.ts +2 -2
  43. package/src/kysely-secret-service.ts +2 -2
  44. package/src/kysely-services.test.ts +26 -0
  45. package/src/kysely-session-store.ts +2 -2
  46. package/src/kysely-tables.test.ts +1 -12
  47. package/src/kysely-tables.ts +29 -0
  48. package/src/kysely-virtual-user-run-store.test.ts +22 -6
  49. package/src/kysely-virtual-user-run-store.ts +2 -2
  50. package/src/kysely-webhook-service.test.ts +4 -0
  51. package/src/kysely-webhook-service.ts +2 -2
  52. package/src/kysely-workflow-mirror.test.ts +2 -0
  53. package/src/kysely-workflow-mirror.ts +3 -3
  54. package/src/kysely-workflow-service.test.ts +114 -0
  55. package/src/kysely-workflow-service.ts +99 -11
  56. package/src/schema/agent.schema.ts +1 -0
  57. package/src/schema/audit.schema.ts +6 -4
  58. package/src/schema/channel.schema.ts +1 -0
  59. package/src/schema/credential.schema.ts +1 -0
  60. package/src/schema/index.ts +67 -52
  61. package/src/schema/pikku-schema.types.ts +20 -0
  62. package/src/schema/schema.test.ts +86 -29
  63. package/src/schema/scope.schema.ts +1 -0
  64. package/src/schema/virtual-user.schema.ts +2 -1
  65. package/src/schema/webhook.schema.ts +1 -0
  66. package/src/schema/workflow.schema.ts +1 -0
@@ -324,6 +324,34 @@ export interface VirtualUserRunTable {
324
324
  finishedAt: Date | string | null
325
325
  }
326
326
 
327
+ /**
328
+ * One audit event. Every column is text on every engine so a locally-run
329
+ * project and a deployed stage write rows the same reader can read; the JSON
330
+ * columns (`tables`, `changedCols`, `event`, `old`, `data`) are serialised by
331
+ * {@link KyselyAuditService} rather than by a plugin.
332
+ */
333
+ export interface AuditTable {
334
+ auditId: string
335
+ /** ISO 8601, because string ordering is chronological ordering. */
336
+ occurredAt: string
337
+ type: string
338
+ source: Generated<string>
339
+ outcome: string | null
340
+ functionId: string | null
341
+ wireType: string | null
342
+ traceId: string | null
343
+ transactionId: string | null
344
+ queryId: string | null
345
+ userId: string | null
346
+ orgId: string | null
347
+ pikkuUserId: string | null
348
+ tables: string | null
349
+ changedCols: string | null
350
+ event: string | null
351
+ old: string | null
352
+ data: string | null
353
+ }
354
+
327
355
  export interface KyselyPikkuDB {
328
356
  pikkuScopes: PikkuScopesTable
329
357
  pikkuRoles: PikkuRolesTable
@@ -354,4 +382,5 @@ export interface KyselyPikkuDB {
354
382
  webhookDelivery: WebhookDeliveryTable
355
383
  webhookDeliveryAttempt: WebhookDeliveryAttemptTable
356
384
  virtualUserRun: VirtualUserRunTable
385
+ audit: AuditTable
357
386
  }
@@ -6,6 +6,7 @@ import { CamelCasePlugin, Kysely, SqliteDialect } from 'kysely'
6
6
  import { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js'
7
7
  import { SerializePlugin } from './serialize-plugin.js'
8
8
  import type { KyselyPikkuDB } from './kysely-tables.js'
9
+ import { applyPikkuSchemas, virtualUserSchema } from './schema/index.js'
9
10
 
10
11
  // SerializePlugin is here because most projects in this package run with it, and
11
12
  // it is the harder case: it deserialises JSON columns the store has already
@@ -16,6 +17,12 @@ const newDb = () =>
16
17
  plugins: [new CamelCasePlugin(), new SerializePlugin()],
17
18
  })
18
19
 
20
+ const migratedDb = async () => {
21
+ const db = newDb()
22
+ await applyPikkuSchemas(db, [virtualUserSchema])
23
+ return db
24
+ }
25
+
19
26
  const TALLY = {
20
27
  steps: 12,
21
28
  calls: 30,
@@ -36,15 +43,23 @@ describe('KyselyVirtualUserRunStore', () => {
36
43
  let db: Kysely<KyselyPikkuDB>
37
44
  let store: KyselyVirtualUserRunStore
38
45
 
39
- beforeEach(() => {
40
- db = newDb()
46
+ beforeEach(async () => {
47
+ db = await migratedDb()
41
48
  store = new KyselyVirtualUserRunStore(db)
42
49
  })
43
50
 
44
- // The runtime never needs this table, so it arrives with the feature that
45
- // fills it rather than in every database — which means the first write has to
46
- // create it.
47
- test('creates its table on first use', async () => {
51
+ // The runtime never creates this table, so a project that skipped the
52
+ // migration has to be told which command writes it rather than quietly
53
+ // getting one at first use.
54
+ test('refuses to start against a database that never migrated it', async () => {
55
+ const store = new KyselyVirtualUserRunStore(newDb())
56
+ await assert.rejects(
57
+ store.start({ persona: 'susan', disposition: 'realistic', seed: 42 }),
58
+ /pikku db generate/
59
+ )
60
+ })
61
+
62
+ test('writes against a migrated table', async () => {
48
63
  const runId = await store.start({
49
64
  persona: 'susan',
50
65
  disposition: 'realistic',
@@ -184,6 +199,7 @@ describe('KyselyVirtualUserRunStore', () => {
184
199
  dialect: new SqliteDialect({ database: new Database(':memory:') }),
185
200
  plugins: [new CamelCasePlugin()],
186
201
  })
202
+ await applyPikkuSchemas(bare, [virtualUserSchema])
187
203
  const bareStore = new KyselyVirtualUserRunStore(bare)
188
204
 
189
205
  const runId = await bareStore.start({
@@ -9,7 +9,7 @@ import type {
9
9
  } from '@pikku/core/virtual-user'
10
10
  import type { KyselyPikkuDB, VirtualUserRunTable } from './kysely-tables.js'
11
11
  import { parseJson } from './kysely-json.js'
12
- import { ensurePikkuSchema } from './schema/index.js'
12
+ import { requirePikkuSchema } from './schema/index.js'
13
13
  import { virtualUserSchema } from './schema/virtual-user.schema.js'
14
14
 
15
15
  /**
@@ -43,7 +43,7 @@ export class KyselyVirtualUserRunStore implements VirtualUserRunStore {
43
43
  */
44
44
  public async init(): Promise<void> {
45
45
  if (this.initialized) return
46
- await ensurePikkuSchema(this.db, virtualUserSchema)
46
+ await requirePikkuSchema(this.db, virtualUserSchema)
47
47
  this.initialized = true
48
48
  }
49
49
 
@@ -7,6 +7,7 @@ import { setSingletonServices } from '@pikku/core/state'
7
7
  import type { KyselyPikkuDB } from './kysely-tables.js'
8
8
  import { SerializePlugin } from './serialize-plugin.js'
9
9
  import { KyselyWebhookService } from './kysely-webhook-service.js'
10
+ import { applyPikkuSchemas, webhookSchema } from './schema/index.js'
10
11
 
11
12
  const noopLogger = { error() {}, info() {}, warn() {}, debug() {} }
12
13
 
@@ -50,6 +51,7 @@ describe('KyselyWebhookService', () => {
50
51
  test('send() inserts a pending delivery and enqueues with deliveryId === jobId', async () => {
51
52
  const db = createDb()
52
53
  const { added, queue } = createQueue()
54
+ await applyPikkuSchemas(db, [webhookSchema])
53
55
  const service = new KyselyWebhookService(queue as any, db)
54
56
  await service.init()
55
57
 
@@ -76,6 +78,7 @@ describe('KyselyWebhookService', () => {
76
78
  test('recordAttempt() appends attempts and rolls status forward', async () => {
77
79
  const db = createDb()
78
80
  const { queue } = createQueue()
81
+ await applyPikkuSchemas(db, [webhookSchema])
79
82
  const service = new KyselyWebhookService(queue as any, db)
80
83
  await service.init()
81
84
 
@@ -104,6 +107,7 @@ describe('KyselyWebhookService', () => {
104
107
  test('listDeliveries() filters by organization', async () => {
105
108
  const db = createDb()
106
109
  const { queue } = createQueue()
110
+ await applyPikkuSchemas(db, [webhookSchema])
107
111
  const service = new KyselyWebhookService(queue as any, db)
108
112
  await service.init()
109
113
 
@@ -10,7 +10,7 @@ import {
10
10
  } from '@pikku/core/services'
11
11
  import type { Kysely } from 'kysely'
12
12
  import type { KyselyPikkuDB } from './kysely-tables.js'
13
- import { ensurePikkuSchema } from './schema/index.js'
13
+ import { requirePikkuSchema } from './schema/index.js'
14
14
  import { webhookSchema } from './schema/webhook.schema.js'
15
15
 
16
16
  /**
@@ -32,7 +32,7 @@ export class KyselyWebhookService extends QueueWebhookService {
32
32
 
33
33
  public async init(): Promise<void> {
34
34
  if (this.initialized) return
35
- await ensurePikkuSchema(this.db, webhookSchema)
35
+ await requirePikkuSchema(this.db, webhookSchema)
36
36
  this.initialized = true
37
37
  }
38
38
 
@@ -7,6 +7,7 @@ import Database from 'better-sqlite3'
7
7
  import type { KyselyPikkuDB } from './kysely-tables.js'
8
8
  import { KyselyWorkflowMirror } from './kysely-workflow-mirror.js'
9
9
  import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js'
10
+ import { applyPikkuSchemas, workflowSchema } from './schema/index.js'
10
11
 
11
12
  let db: Kysely<KyselyPikkuDB>
12
13
  let mirror: KyselyWorkflowMirror
@@ -20,6 +21,7 @@ beforeEach(async () => {
20
21
  dialect: new SqliteDialect({ database: new Database(':memory:') }),
21
22
  plugins: [new CamelCasePlugin(), new SerializePlugin()],
22
23
  })
24
+ await applyPikkuSchemas(db, [workflowSchema])
23
25
  mirror = new KyselyWorkflowMirror(db)
24
26
  await mirror.init()
25
27
  runService = new KyselyWorkflowRunService(db)
@@ -9,7 +9,7 @@ import type {
9
9
  } from '@pikku/core/workflow'
10
10
  import type { Kysely } from 'kysely'
11
11
  import type { KyselyPikkuDB } from './kysely-tables.js'
12
- import { ensurePikkuSchema } from './schema/index.js'
12
+ import { requirePikkuSchema } from './schema/index.js'
13
13
  import { workflowSchema } from './schema/workflow.schema.js'
14
14
 
15
15
  /**
@@ -33,14 +33,14 @@ export class KyselyWorkflowMirror implements WorkflowRunMirror {
33
33
  * Create the underlying tables if none of them exist yet.
34
34
  *
35
35
  * Safe to call from either the mirror or `KyselyWorkflowService.init()` —
36
- * both apply the same `workflowSchema`, and `ensurePikkuSchema` is a no-op
36
+ * both apply the same `workflowSchema`, and `requirePikkuSchema` is a no-op
37
37
  * once the tables are there. A database holding only *part* of the schema
38
38
  * throws rather than filling in the rest: something else owns those tables,
39
39
  * and boot is not where that gets reconciled.
40
40
  */
41
41
  public async init(): Promise<void> {
42
42
  if (this.initialized) return
43
- await ensurePikkuSchema(this.db, workflowSchema)
43
+ await requirePikkuSchema(this.db, workflowSchema)
44
44
  this.initialized = true
45
45
  }
46
46
 
@@ -3,9 +3,11 @@ import assert from 'node:assert/strict'
3
3
  import { CamelCasePlugin, Kysely, SqliteDialect, sql } from 'kysely'
4
4
  import Database from 'better-sqlite3'
5
5
 
6
+ import type { StepState } from '@pikku/core/ecosystem/workflow'
6
7
  import type { KyselyPikkuDB } from './kysely-tables.js'
7
8
  import { SerializePlugin } from './serialize-plugin.js'
8
9
  import { KyselyWorkflowService } from './kysely-workflow-service.js'
10
+ import { applyPikkuSchemas, workflowSchema } from './schema/index.js'
9
11
 
10
12
  let db: Kysely<KyselyPikkuDB>
11
13
  let service: KyselyWorkflowService
@@ -30,6 +32,7 @@ const createDb = () => {
30
32
 
31
33
  beforeEach(async () => {
32
34
  db = createDb()
35
+ await applyPikkuSchemas(db, [workflowSchema])
33
36
  service = new KyselyWorkflowService(db, { wireQueues: false } as any)
34
37
  await service.init()
35
38
  })
@@ -428,3 +431,114 @@ describe('KyselyWorkflowService — a transition that addresses no history row',
428
431
  assert.equal(history[0]!.status, 'succeeded')
429
432
  })
430
433
  })
434
+
435
+ /**
436
+ * Dispatch is at-least-once — the relay re-dispatches a step it believes was
437
+ * dropped, and a queue can redeliver a job it already handed out — so the claim
438
+ * is the only thing standing between a duplicate dispatch and a second
439
+ * execution of a side-effecting step.
440
+ */
441
+ describe('KyselyWorkflowService — claiming a step for execution', () => {
442
+ const claim = (runId: string, stepName: string, rpcName = 'rpc.fn') =>
443
+ (service as any).claimStepForExecution(
444
+ runId,
445
+ stepName,
446
+ rpcName
447
+ ) as Promise<StepState | null>
448
+
449
+ test('two dispatches racing for the same pending step: exactly one wins', async () => {
450
+ const runId = await seedRun()
451
+ await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
452
+
453
+ const claims = await Promise.all([claim(runId, 's1'), claim(runId, 's1')])
454
+ const winners = claims.filter((c) => c !== null)
455
+
456
+ assert.equal(
457
+ winners.length,
458
+ 1,
459
+ `both dispatches claimed the step, so a side-effecting step would run twice: ${JSON.stringify(claims)}`
460
+ )
461
+ assert.equal((await service.getStepState(runId, 's1')).status, 'running')
462
+ })
463
+
464
+ test('two dispatches racing to retry the same failed step: exactly one wins', async () => {
465
+ const runId = await seedRun()
466
+ const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
467
+ await service.setStepRunning(step.stepId)
468
+ await service.setStepError(step.stepId, new Error('boom'))
469
+
470
+ const claims = await Promise.all([claim(runId, 's1'), claim(runId, 's1')])
471
+ const winners = claims.filter((c) => c !== null)
472
+
473
+ assert.equal(
474
+ winners.length,
475
+ 1,
476
+ `both dispatches started a retry, so the failed step would be retried twice concurrently: ${JSON.stringify(claims)}`
477
+ )
478
+ assert.equal(
479
+ (await service.getStepState(runId, 's1')).attemptCount,
480
+ 2,
481
+ 'the retry raced itself and burned more than one attempt'
482
+ )
483
+ })
484
+
485
+ test('a step already claimed is not claimable again', async () => {
486
+ const runId = await seedRun()
487
+ await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
488
+
489
+ assert.notEqual(await claim(runId, 's1'), null)
490
+ assert.equal(
491
+ await claim(runId, 's1'),
492
+ null,
493
+ 'a redelivered job re-claimed a step that is already running'
494
+ )
495
+ })
496
+
497
+ test('a succeeded step is not claimable', async () => {
498
+ const runId = await seedRun()
499
+ const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
500
+ await service.setStepRunning(step.stepId)
501
+ await service.setStepResult(step.stepId, { ok: true })
502
+
503
+ assert.equal(await claim(runId, 's1'), null)
504
+ })
505
+
506
+ test('a claim that fails releases the step, so it is retryable', async () => {
507
+ const runId = await seedRun()
508
+ const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
509
+
510
+ const first = await claim(runId, 's1')
511
+ assert.notEqual(first, null)
512
+ await service.setStepError(step.stepId, new Error('boom'))
513
+
514
+ const retry = await claim(runId, 's1')
515
+ assert.notEqual(
516
+ retry,
517
+ null,
518
+ 'the failed step stayed claimed, so it can never be retried'
519
+ )
520
+ assert.equal(retry?.status, 'running')
521
+ assert.equal(retry?.attemptCount, 2)
522
+ })
523
+
524
+ test('a claim for a different function than the step was dispatched with is refused', async () => {
525
+ const runId = await seedRun()
526
+ await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
527
+
528
+ await assert.rejects(() => claim(runId, 's1', 'rpc.other'))
529
+ assert.equal(
530
+ (await service.getStepState(runId, 's1')).status,
531
+ 'pending',
532
+ 'the mismatched claim moved the step anyway'
533
+ )
534
+ })
535
+
536
+ test('a scheduled step is claimable, and only once', async () => {
537
+ const runId = await seedRun()
538
+ const step = await service.insertStepState(runId, 's1', 'rpc.fn', { x: 1 })
539
+ await service.setStepScheduled(step.stepId)
540
+
541
+ const claims = await Promise.all([claim(runId, 's1'), claim(runId, 's1')])
542
+ assert.equal(claims.filter((c) => c !== null).length, 1)
543
+ })
544
+ })
@@ -1,5 +1,8 @@
1
1
  import type { SerializedError } from '@pikku/core/errors'
2
- import { PikkuWorkflowService } from '@pikku/core/workflow'
2
+ import {
3
+ PikkuWorkflowService,
4
+ WorkflowStepFunctionMismatchError,
5
+ } from '@pikku/core/workflow'
3
6
  import type {
4
7
  WorkflowPlannedStep,
5
8
  WorkflowQueueOptions,
@@ -14,7 +17,7 @@ import { sql, type Kysely } from 'kysely'
14
17
  import type { KyselyPikkuDB } from './kysely-tables.js'
15
18
  import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js'
16
19
  import { parseJson } from './kysely-json.js'
17
- import { ensurePikkuSchema } from './schema/index.js'
20
+ import { requirePikkuSchema } from './schema/index.js'
18
21
  import { workflowSchema } from './schema/workflow.schema.js'
19
22
 
20
23
  export class KyselyWorkflowService extends PikkuWorkflowService {
@@ -31,7 +34,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
31
34
 
32
35
  public async init(): Promise<void> {
33
36
  if (this.initialized) return
34
- await ensurePikkuSchema(this.db, workflowSchema)
37
+ await requirePikkuSchema(this.db, workflowSchema)
35
38
  this.initialized = true
36
39
  }
37
40
 
@@ -460,6 +463,16 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
460
463
  return fn()
461
464
  }
462
465
 
466
+ /**
467
+ * A pass-through, and deliberately so: the one decision that must exclude —
468
+ * claiming a step to execute it — is made by `claimStepForExecution` below as
469
+ * a single conditional write, which needs no lock to be exclusive.
470
+ *
471
+ * What is left are the suspend and approval sections in the engine, where the
472
+ * lock is genuine mutual exclusion rather than a claim. A dialect with a real
473
+ * primitive overrides this to cover them — `kysely-postgres` with
474
+ * `pg_advisory_xact_lock`, `kysely-mysql` with `GET_LOCK`.
475
+ */
463
476
  async withStepLock<T>(
464
477
  _runId: string,
465
478
  _stepName: string,
@@ -468,6 +481,79 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
468
481
  return fn()
469
482
  }
470
483
 
484
+ /**
485
+ * Claim the step with a status-guarded `UPDATE` and read the affected-row
486
+ * count: the database decides the winner in one statement, so two dispatches
487
+ * racing for the same step cannot both proceed.
488
+ *
489
+ * This replaces the read-then-write the base engine does under `withStepLock`,
490
+ * which is only as exclusive as that lock — and every dialect but Postgres and
491
+ * MySQL inherits a pass-through. A conditional update needs no advisory-lock
492
+ * primitive, so every SQL dialect gets the same guarantee.
493
+ *
494
+ * The winner then goes through the ordinary transition methods, so history
495
+ * rows and the mirror see exactly what they saw before.
496
+ */
497
+ protected override async claimStepForExecution(
498
+ runId: string,
499
+ stepName: string,
500
+ rpcName: string
501
+ ): Promise<StepState | null> {
502
+ const stepState = await this.getStepState(runId, stepName)
503
+
504
+ // knowledge: decisions/security/a-step-runs-the-function-the-workflow-dispatched-it-with.md
505
+ if (
506
+ stepState.rpcName !== undefined &&
507
+ stepState.rpcName !== (rpcName ?? null)
508
+ ) {
509
+ throw new WorkflowStepFunctionMismatchError(runId, stepName)
510
+ }
511
+
512
+ if (stepState.status === 'succeeded' || stepState.status === 'running') {
513
+ return null
514
+ }
515
+
516
+ if (stepState.status === 'failed') {
517
+ if (!(await this.claimStepStatus(stepState.stepId, ['failed']))) {
518
+ return null
519
+ }
520
+ return this.createRetryAttempt(stepState.stepId, 'running')
521
+ }
522
+
523
+ if (stepState.status === 'pending' || stepState.status === 'scheduled') {
524
+ if (
525
+ !(await this.claimStepStatus(stepState.stepId, [
526
+ 'pending',
527
+ 'scheduled',
528
+ ]))
529
+ ) {
530
+ return null
531
+ }
532
+ await this.setStepRunning(stepState.stepId)
533
+ return { ...stepState, status: 'running' }
534
+ }
535
+
536
+ return stepState
537
+ }
538
+
539
+ /**
540
+ * Move a step to `running` only if it is still in one of `from`, reporting
541
+ * whether this caller is the one that moved it.
542
+ */
543
+ private async claimStepStatus(
544
+ stepId: string,
545
+ from: StepStatus[]
546
+ ): Promise<boolean> {
547
+ const claimed = await this.db
548
+ .updateTable('workflowStep')
549
+ .set({ status: 'running', updatedAt: new Date() })
550
+ .where('workflowStepId', '=', stepId)
551
+ .where('status', 'in', from)
552
+ .executeTakeFirst()
553
+
554
+ return Number(claimed?.numUpdatedRows ?? 0n) > 0
555
+ }
556
+
471
557
  async getCompletedGraphState(runId: string): Promise<{
472
558
  completedNodeIds: string[]
473
559
  failedNodeIds: string[]
@@ -606,16 +692,18 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
606
692
  }
607
693
 
608
694
  /**
609
- * The undispatched-step query, shared by the dialects that can safely relay.
695
+ * The undispatched-step query, shared by every dialect.
610
696
  *
611
- * Deliberately NOT an override of `findUndispatchedSteps`: this class's
612
- * `withStepLock` is a pass-through, so a subclass inheriting it (kysely-sqlite)
613
- * has no atomic claim, and the relay's redundant dispatches would become
614
- * double executions. A dialect opts in by overriding `findUndispatchedSteps`
615
- * to call this — which `kysely-postgres` and `kysely-mysql` do, having real
616
- * locks.
697
+ * It used to be deliberately NOT an override, because this class's
698
+ * `withStepLock` is a pass-through: a subclass inheriting it (kysely-sqlite)
699
+ * had no atomic claim, so the relay's redundant dispatches would have become
700
+ * double executions, and only `kysely-postgres` and `kysely-mysql` opted in
701
+ * on the strength of their real locks. `claimStepForExecution` no longer
702
+ * needs that lock — the claim is a status-guarded `UPDATE`, atomic in every
703
+ * dialect — so the relay is safe here for all of them and the override lives
704
+ * in the base.
617
705
  */
618
- protected async queryUndispatchedSteps(
706
+ protected override async findUndispatchedSteps(
619
707
  before: Date,
620
708
  limit: number
621
709
  ): Promise<Array<{ runId: string; stepId: string }>> {
@@ -13,6 +13,7 @@ import type { PikkuSchema } from './pikku-schema.types.js'
13
13
  */
14
14
  export const agentSchema: PikkuSchema = {
15
15
  name: 'agent',
16
+ ownedBy: ['agentStorage', 'agentRunState', 'agentRunService'],
16
17
  statements: [
17
18
  (db) =>
18
19
  db.schema
@@ -3,10 +3,11 @@ import type { PikkuSchema } from './pikku-schema.types.js'
3
3
  /**
4
4
  * The `audit` table {@link KyselyAuditService} writes to and reads back.
5
5
  *
6
- * Deliberately not in `pikkuSchemas`: the runtime never needs this table, only
7
- * a project that has wired a durable audit sink does, and creating it for
8
- * everyone would put an empty table in every database. `KyselyAuditService.init()`
9
- * applies it, so it arrives exactly when the sink that fills it does.
6
+ * Gated by `ownedBy` rather than written for everyone: only a project that has
7
+ * wired a durable audit sink needs this table, and generating it for the rest
8
+ * would put an empty table in every database. `KyselyAuditService.init()`
9
+ * requires it and never creates it, so it arrives with the migration `pikku db
10
+ * generate` writes for the sink that fills it.
10
11
  *
11
12
  * Every column is text on every engine, matching the platform audit-queue
12
13
  * consumer's row shape — a locally-run project and a deployed stage write rows
@@ -16,6 +17,7 @@ import type { PikkuSchema } from './pikku-schema.types.js'
16
17
  */
17
18
  export const auditSchema: PikkuSchema = {
18
19
  name: 'audit',
20
+ ownedBy: ['audit', 'auditLog'],
19
21
  statements: [
20
22
  (db) =>
21
23
  db.schema
@@ -4,6 +4,7 @@ import type { PikkuSchema } from './pikku-schema.types.js'
4
4
  /** Channels and their topic subscriptions, also read by the event-hub store. */
5
5
  export const channelSchema: PikkuSchema = {
6
6
  name: 'channel',
7
+ ownedBy: ['channelStore', 'eventHub'],
7
8
  statements: [
8
9
  (db) =>
9
10
  db.schema
@@ -13,6 +13,7 @@ import { rawStatement, type PikkuSchema } from './pikku-schema.types.js'
13
13
  */
14
14
  export const credentialSchema: PikkuSchema = {
15
15
  name: 'credential',
16
+ ownedBy: ['credentialService'],
16
17
  statements: [
17
18
  (db) =>
18
19
  db.schema