@pikku/kysely 0.13.19 → 0.13.21

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 (75) hide show
  1. package/CHANGELOG.md +174 -0
  2. package/dist/src/index.d.ts +3 -2
  3. package/dist/src/index.js +2 -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 +74 -0
  14. package/dist/src/kysely-virtual-user-run-store.d.ts +7 -19
  15. package/dist/src/kysely-virtual-user-run-store.js +71 -4
  16. package/dist/src/kysely-virtual-user-schedule-store.d.ts +33 -0
  17. package/dist/src/kysely-virtual-user-schedule-store.js +151 -0
  18. package/dist/src/kysely-webhook-service.js +2 -2
  19. package/dist/src/kysely-workflow-mirror.d.ts +1 -1
  20. package/dist/src/kysely-workflow-mirror.js +3 -3
  21. package/dist/src/kysely-workflow-service.js +2 -2
  22. package/dist/src/schema/agent.schema.js +1 -0
  23. package/dist/src/schema/audit.schema.d.ts +5 -4
  24. package/dist/src/schema/audit.schema.js +6 -4
  25. package/dist/src/schema/channel.schema.js +1 -0
  26. package/dist/src/schema/credential.schema.js +1 -0
  27. package/dist/src/schema/index.d.ts +48 -26
  28. package/dist/src/schema/index.js +59 -46
  29. package/dist/src/schema/pikku-schema.types.d.ts +20 -0
  30. package/dist/src/schema/scope.schema.js +1 -0
  31. package/dist/src/schema/virtual-user-schedule.schema.d.ts +15 -0
  32. package/dist/src/schema/virtual-user-schedule.schema.js +43 -0
  33. package/dist/src/schema/virtual-user.schema.d.ts +2 -1
  34. package/dist/src/schema/virtual-user.schema.js +41 -2
  35. package/dist/src/schema/webhook.schema.js +1 -0
  36. package/dist/src/schema/workflow.schema.js +1 -0
  37. package/dist/tsconfig.tsbuildinfo +1 -1
  38. package/package.json +2 -2
  39. package/src/index.ts +15 -2
  40. package/src/kysely-agent-run-state-service.ts +2 -2
  41. package/src/kysely-agent-storage-service.ts +2 -2
  42. package/src/kysely-audit-service.test.ts +2 -10
  43. package/src/kysely-audit-service.ts +2 -2
  44. package/src/kysely-channel-store.ts +2 -2
  45. package/src/kysely-credential-service.ts +2 -2
  46. package/src/kysely-deployment-service.ts +2 -2
  47. package/src/kysely-scope-service.test.ts +2 -0
  48. package/src/kysely-scope-service.ts +2 -2
  49. package/src/kysely-secret-service.ts +2 -2
  50. package/src/kysely-services.test.ts +26 -0
  51. package/src/kysely-session-store.ts +2 -2
  52. package/src/kysely-tables.test.ts +1 -12
  53. package/src/kysely-tables.ts +77 -0
  54. package/src/kysely-virtual-user-run-store.test.ts +164 -7
  55. package/src/kysely-virtual-user-run-store.ts +92 -6
  56. package/src/kysely-virtual-user-schedule-store.test.ts +187 -0
  57. package/src/kysely-virtual-user-schedule-store.ts +187 -0
  58. package/src/kysely-webhook-service.test.ts +4 -0
  59. package/src/kysely-webhook-service.ts +2 -2
  60. package/src/kysely-workflow-mirror.test.ts +2 -0
  61. package/src/kysely-workflow-mirror.ts +3 -3
  62. package/src/kysely-workflow-service.test.ts +3 -1
  63. package/src/kysely-workflow-service.ts +2 -2
  64. package/src/schema/agent.schema.ts +1 -0
  65. package/src/schema/audit.schema.ts +6 -4
  66. package/src/schema/channel.schema.ts +1 -0
  67. package/src/schema/credential.schema.ts +1 -0
  68. package/src/schema/index.ts +70 -52
  69. package/src/schema/pikku-schema.types.ts +20 -0
  70. package/src/schema/schema.test.ts +86 -29
  71. package/src/schema/scope.schema.ts +1 -0
  72. package/src/schema/virtual-user-schedule.schema.ts +48 -0
  73. package/src/schema/virtual-user.schema.ts +43 -2
  74. package/src/schema/webhook.schema.ts +1 -0
  75. package/src/schema/workflow.schema.ts +1 -0
@@ -0,0 +1,187 @@
1
+ import type { Kysely, Selectable } from 'kysely'
2
+ import type {
3
+ VirtualUserBudget,
4
+ VirtualUserDisposition,
5
+ VirtualUserScheduleInput,
6
+ VirtualUserScheduleRecord,
7
+ VirtualUserScheduleStore,
8
+ } from '@pikku/core/virtual-user'
9
+ import {
10
+ DEFAULT_MAX_INTERVAL_MS,
11
+ DEFAULT_MIN_INTERVAL_MS,
12
+ } from '@pikku/core/virtual-user'
13
+ import type {
14
+ KyselyPikkuDB,
15
+ VirtualUserScheduleTable,
16
+ } from './kysely-tables.js'
17
+ import { parseJson } from './kysely-json.js'
18
+ import { requirePikkuSchema } from './schema/index.js'
19
+ import { virtualUserScheduleSchema } from './schema/virtual-user-schedule.schema.js'
20
+
21
+ /**
22
+ * Keeps each persona's cadence in a `virtualUserSchedule` table.
23
+ *
24
+ * Wiring this is what turns virtual users from something a person triggers into
25
+ * something that happens: without it, {@link KyselyVirtualUserRunStore} still
26
+ * records every run, and every run still had someone behind it. Two services
27
+ * rather than one so that an app can want the history without wanting the bill.
28
+ *
29
+ * Reads and writes the same way the run store does — JSON as text, timestamps
30
+ * as ISO strings, flags as 0/1 — so a project on SQLite and one on Postgres
31
+ * hold the same bytes and `SerializePlugin` is optional on both.
32
+ */
33
+ export class KyselyVirtualUserScheduleStore implements VirtualUserScheduleStore {
34
+ private initialized = false
35
+
36
+ constructor(private db: Kysely<KyselyPikkuDB>) {}
37
+
38
+ public async init(): Promise<void> {
39
+ if (this.initialized) return
40
+ await requirePikkuSchema(this.db, virtualUserScheduleSchema)
41
+ this.initialized = true
42
+ }
43
+
44
+ async set(
45
+ schedule: VirtualUserScheduleInput
46
+ ): Promise<VirtualUserScheduleRecord> {
47
+ await this.init()
48
+ // Read-then-write rather than a partial upsert: a schedule is configuration
49
+ // written by a person, and the alternative is two dialects of "leave the
50
+ // columns I did not mention alone".
51
+ const existing = await this.get(schedule.persona)
52
+ const merged: VirtualUserScheduleRecord = {
53
+ persona: schedule.persona,
54
+ enabled: schedule.enabled ?? existing?.enabled ?? false,
55
+ disposition: schedule.disposition ?? existing?.disposition ?? 'realistic',
56
+ goals: [...(schedule.goals ?? existing?.goals ?? [])],
57
+ budget:
58
+ schedule.budget === undefined
59
+ ? (existing?.budget ?? null)
60
+ : schedule.budget,
61
+ minIntervalMs:
62
+ schedule.minIntervalMs ??
63
+ existing?.minIntervalMs ??
64
+ DEFAULT_MIN_INTERVAL_MS,
65
+ maxIntervalMs:
66
+ schedule.maxIntervalMs ??
67
+ existing?.maxIntervalMs ??
68
+ DEFAULT_MAX_INTERVAL_MS,
69
+ // A new schedule is due immediately. Enabling one is a deliberate act, and
70
+ // waiting a random half-day to find out whether it works is not a feature.
71
+ nextRunAt: schedule.nextRunAt ?? existing?.nextRunAt ?? new Date(),
72
+ lastRunId: existing?.lastRunId ?? null,
73
+ lastRunAt: existing?.lastRunAt ?? null,
74
+ }
75
+
76
+ const values = {
77
+ persona: merged.persona,
78
+ enabled: merged.enabled ? 1 : 0,
79
+ disposition: merged.disposition,
80
+ goals: JSON.stringify(merged.goals),
81
+ budget: merged.budget ? JSON.stringify(merged.budget) : null,
82
+ minIntervalMs: String(merged.minIntervalMs),
83
+ maxIntervalMs: String(merged.maxIntervalMs),
84
+ nextRunAt: merged.nextRunAt.toISOString(),
85
+ }
86
+
87
+ await this.db
88
+ .insertInto('virtualUserSchedule')
89
+ .values(values)
90
+ .onConflict((oc) =>
91
+ oc.column('persona').doUpdateSet({
92
+ enabled: values.enabled,
93
+ disposition: values.disposition,
94
+ goals: values.goals,
95
+ budget: values.budget,
96
+ minIntervalMs: values.minIntervalMs,
97
+ maxIntervalMs: values.maxIntervalMs,
98
+ nextRunAt: values.nextRunAt,
99
+ })
100
+ )
101
+ .execute()
102
+
103
+ return merged
104
+ }
105
+
106
+ async get(persona: string): Promise<VirtualUserScheduleRecord | null> {
107
+ await this.init()
108
+ const row = await this.db
109
+ .selectFrom('virtualUserSchedule')
110
+ .selectAll()
111
+ .where('persona', '=', persona)
112
+ .executeTakeFirst()
113
+ return row ? this.toRecord(row) : null
114
+ }
115
+
116
+ async list(): Promise<VirtualUserScheduleRecord[]> {
117
+ await this.init()
118
+ const rows = await this.db
119
+ .selectFrom('virtualUserSchedule')
120
+ .selectAll()
121
+ .orderBy('persona', 'asc')
122
+ .execute()
123
+ return rows.map((row) => this.toRecord(row))
124
+ }
125
+
126
+ async due(now: Date): Promise<VirtualUserScheduleRecord[]> {
127
+ await this.init()
128
+ const rows = await this.db
129
+ .selectFrom('virtualUserSchedule')
130
+ .selectAll()
131
+ .where('enabled', '=', 1)
132
+ .where('nextRunAt', '<=', now.toISOString())
133
+ .orderBy('nextRunAt', 'asc')
134
+ .execute()
135
+ return rows.map((row) => this.toRecord(row))
136
+ }
137
+
138
+ async claim(
139
+ persona: string,
140
+ claim: { from: Date; nextRunAt: Date; runId: string | null; at: Date }
141
+ ): Promise<boolean> {
142
+ await this.init()
143
+ const result = await this.db
144
+ .updateTable('virtualUserSchedule')
145
+ .set({
146
+ nextRunAt: claim.nextRunAt.toISOString(),
147
+ // A claim with no run id is the one written before dispatch, and must
148
+ // not blank out which run this persona last managed to start.
149
+ ...(claim.runId
150
+ ? { lastRunId: claim.runId, lastRunAt: claim.at.toISOString() }
151
+ : {}),
152
+ })
153
+ .where('persona', '=', persona)
154
+ // The compare-and-set: whoever writes first moves `nextRunAt`, and every
155
+ // other tick holding the value they read matches nothing.
156
+ .where('nextRunAt', '=', claim.from.toISOString())
157
+ .executeTakeFirst()
158
+ return Number(result.numUpdatedRows ?? 0) > 0
159
+ }
160
+
161
+ async remove(persona: string): Promise<void> {
162
+ await this.init()
163
+ await this.db
164
+ .deleteFrom('virtualUserSchedule')
165
+ .where('persona', '=', persona)
166
+ .execute()
167
+ }
168
+
169
+ private toRecord(
170
+ row: Selectable<VirtualUserScheduleTable>
171
+ ): VirtualUserScheduleRecord {
172
+ return {
173
+ persona: row.persona,
174
+ enabled: Boolean(row.enabled),
175
+ disposition: row.disposition as VirtualUserDisposition,
176
+ goals: parseJson(row.goals) ?? [],
177
+ budget: row.budget
178
+ ? ((parseJson(row.budget) ?? null) as VirtualUserBudget | null)
179
+ : null,
180
+ minIntervalMs: Number(row.minIntervalMs),
181
+ maxIntervalMs: Number(row.maxIntervalMs),
182
+ nextRunAt: new Date(row.nextRunAt),
183
+ lastRunId: row.lastRunId,
184
+ lastRunAt: row.lastRunAt ? new Date(row.lastRunAt) : null,
185
+ }
186
+ }
187
+ }
@@ -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,10 +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
+ import type { StepState } from '@pikku/core/workflow'
7
7
  import type { KyselyPikkuDB } from './kysely-tables.js'
8
8
  import { SerializePlugin } from './serialize-plugin.js'
9
9
  import { KyselyWorkflowService } from './kysely-workflow-service.js'
10
+ import { applyPikkuSchemas, workflowSchema } from './schema/index.js'
10
11
 
11
12
  let db: Kysely<KyselyPikkuDB>
12
13
  let service: KyselyWorkflowService
@@ -31,6 +32,7 @@ const createDb = () => {
31
32
 
32
33
  beforeEach(async () => {
33
34
  db = createDb()
35
+ await applyPikkuSchemas(db, [workflowSchema])
34
36
  service = new KyselyWorkflowService(db, { wireQueues: false } as any)
35
37
  await service.init()
36
38
  })
@@ -17,7 +17,7 @@ import { sql, type Kysely } from 'kysely'
17
17
  import type { KyselyPikkuDB } from './kysely-tables.js'
18
18
  import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js'
19
19
  import { parseJson } from './kysely-json.js'
20
- import { ensurePikkuSchema } from './schema/index.js'
20
+ import { requirePikkuSchema } from './schema/index.js'
21
21
  import { workflowSchema } from './schema/workflow.schema.js'
22
22
 
23
23
  export class KyselyWorkflowService extends PikkuWorkflowService {
@@ -34,7 +34,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
34
34
 
35
35
  public async init(): Promise<void> {
36
36
  if (this.initialized) return
37
- await ensurePikkuSchema(this.db, workflowSchema)
37
+ await requirePikkuSchema(this.db, workflowSchema)
38
38
  this.initialized = true
39
39
  }
40
40
 
@@ -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
@@ -7,6 +7,7 @@ import {
7
7
  type SchemaRequirement,
8
8
  } from './pikku-schema.types.js'
9
9
  import { agentSchema } from './agent.schema.js'
10
+ import { auditSchema } from './audit.schema.js'
10
11
  import { channelSchema } from './channel.schema.js'
11
12
  import { credentialSchema } from './credential.schema.js'
12
13
  import { deploymentSchema } from './deployment.schema.js'
@@ -15,6 +16,8 @@ import { secretSchema } from './secret.schema.js'
15
16
  import { sessionSchema } from './session.schema.js'
16
17
  import { webhookSchema } from './webhook.schema.js'
17
18
  import { workflowSchema } from './workflow.schema.js'
19
+ import { virtualUserSchema } from './virtual-user.schema.js'
20
+ import { virtualUserScheduleSchema } from './virtual-user-schedule.schema.js'
18
21
 
19
22
  export type {
20
23
  PikkuSchema,
@@ -32,18 +35,18 @@ export {
32
35
  } from './pikku-schema.types.js'
33
36
 
34
37
  /**
35
- * Opt-in, so it is named here rather than folded into `pikkuSchemas` — the
36
- * runtime works without an audit table, and `KyselyAuditService.init()` applies
37
- * this for the projects that wire the sink.
38
- */
39
- export { auditSchema } from './audit.schema.js'
40
-
41
- /**
42
- * Every table the pikku runtime needs, in dependency order.
38
+ * Every table any part of the pikku runtime can need, in dependency order.
43
39
  *
44
40
  * Order is load-bearing, not cosmetic: `scope` has foreign keys onto Better
45
41
  * Auth's `user`, and the tables within each schema reference each other. Sort
46
42
  * this list alphabetically and it stops applying.
43
+ *
44
+ * `audit` and `virtualUser` are in the list now. They used to be left out and
45
+ * created by their own services at boot, which was the only way they ever came
46
+ * into existence — so with the runtime no longer creating anything, leaving
47
+ * them out would mean a wired audit sink starting up against a table that
48
+ * nothing writes a migration for. Both are gated by `ownedBy`, so a project
49
+ * that wires neither still carries neither.
47
50
  */
48
51
  export const pikkuSchemas: PikkuSchema[] = [
49
52
  channelSchema,
@@ -55,8 +58,42 @@ export const pikkuSchemas: PikkuSchema[] = [
55
58
  workflowSchema,
56
59
  agentSchema,
57
60
  scopeSchema,
61
+ auditSchema,
62
+ virtualUserSchema,
63
+ virtualUserScheduleSchema,
58
64
  ]
59
65
 
66
+ export { agentSchema } from './agent.schema.js'
67
+ export { auditSchema } from './audit.schema.js'
68
+ export { channelSchema } from './channel.schema.js'
69
+ export { credentialSchema } from './credential.schema.js'
70
+ export { deploymentSchema } from './deployment.schema.js'
71
+ export { scopeSchema } from './scope.schema.js'
72
+ export { secretSchema } from './secret.schema.js'
73
+ export { sessionSchema } from './session.schema.js'
74
+ export { virtualUserSchema } from './virtual-user.schema.js'
75
+ export { virtualUserScheduleSchema } from './virtual-user-schedule.schema.js'
76
+ export { webhookSchema } from './webhook.schema.js'
77
+ export { workflowSchema } from './workflow.schema.js'
78
+
79
+ /**
80
+ * The schemas a project needs, given the services its code actually reaches.
81
+ *
82
+ * `services` is the inspector's `requiredServices` — the same set that drives
83
+ * service tree-shaking, so a schema is written for exactly the projects whose
84
+ * functions, middleware or wirings reach the service that owns its tables.
85
+ *
86
+ * The gate is one-sided: a schema declaring no `ownedBy` is always in, because
87
+ * nothing a project writes implies it and leaving it out would be a guess.
88
+ */
89
+ export const requiredPikkuSchemas = (
90
+ services: ReadonlySet<string>
91
+ ): PikkuSchema[] =>
92
+ pikkuSchemas.filter(
93
+ (schema) =>
94
+ !schema.ownedBy || schema.ownedBy.some((name) => services.has(name))
95
+ )
96
+
60
97
  /**
61
98
  * Bind a declaration to a database.
62
99
  *
@@ -287,9 +324,6 @@ export const declaredTables = (
287
324
  return tables
288
325
  }
289
326
 
290
- /** What `ensurePikkuSchema` found when it looked. */
291
- export type EnsureOutcome = 'present' | 'created'
292
-
293
327
  /**
294
328
  * What `db` already has, indexed both ways.
295
329
  *
@@ -337,58 +371,42 @@ const isPresent = (existing: ExistingTables, table: DeclaredTable): boolean =>
337
371
  : existing.bare.has(table.name)
338
372
 
339
373
  /**
340
- * Make sure one schema's tables are there, creating them only if none are.
374
+ * Refuse to start unless one schema's tables are already there.
341
375
  *
342
- * What a service calls at boot. It looks before it creates, which is the whole
343
- * difference from the `.ifNotExists()` DDL this replaces: that spelling turned
344
- * every failure into a silent no-op, and hid a foreign key onto `user.id` that
345
- * postgres had been rejecting since the day it was written.
376
+ * What a service calls at boot, and it never issues DDL. The runtime is not an
377
+ * author of the schema: `pikku db generate` writes the declaration down as a
378
+ * migration and `pikku db migrate` applies it, and those two are the only way
379
+ * these tables come into existence. A service that finds them missing says so
380
+ * and stops.
346
381
  *
347
- * A partially-present schema is refused rather than completed. Half a schema
348
- * means something else already applied part of it — a migration, an older
349
- * version, a hand-run script — and creating the remainder at boot would leave
350
- * two authorities over one set of tables, which is the condition all of this
351
- * exists to end.
382
+ * This is the whole point of gating what `db generate` writes. While boot could
383
+ * still create, a schema that generation left out was created silently at
384
+ * startup instead — two authorities over one set of tables, which is the
385
+ * condition all of this exists to end. With creation gone, a schema generation
386
+ * missed is a sentence at startup naming the command that fixes it.
352
387
  *
353
- * Creating at boot is the fallback, not the intent. `pikku db generate` writes
354
- * the declaration down as a migration; a project that has done so takes the
355
- * `present` path and this never issues DDL at all.
388
+ * Half-present is not a distinct case any more. Every missing table is reported
389
+ * the same way, because the remedy is the same one either way.
356
390
  */
357
- export const ensurePikkuSchema = async (
391
+ export const requirePikkuSchema = async (
358
392
  db: Kysely<any>,
359
393
  schema: PikkuSchema
360
- ): Promise<EnsureOutcome> => {
394
+ ): Promise<void> => {
361
395
  const existing = await tablesOf(db)
362
396
  const declared = declaredTables(schema, db)
363
397
  const missing = declared.filter((table) => !isPresent(existing, table))
364
398
 
365
- if (missing.length === 0) return 'present'
366
-
367
- if (missing.length < declared.length) {
368
- throw new Error(
369
- `The '${schema.name}' schema is half applied: ${missing.map(displayName).join(', ')} missing, ` +
370
- `${declared
371
- .filter((table) => isPresent(existing, table))
372
- .map(displayName)
373
- .join(', ')} already there. ` +
374
- 'Something else owns part of it. Reconcile it with a migration — ' +
375
- '`pikku db generate` writes the declaration down — rather than creating the rest at boot.'
376
- )
377
- }
399
+ if (missing.length === 0) return
378
400
 
379
- try {
380
- await applyPikkuSchemas(db, [schema])
381
- return 'created'
382
- } catch (error) {
383
- // Two instances booting cold against one database can both see it empty
384
- // before either has finished creating. If the other one got there, say so;
385
- // anything else is a real failure and still throws. This is a courtesy, not
386
- // a guarantee — a boot race is only fully answered by having run the
387
- // migration, which is what `pikku db generate` is for.
388
- const after = await tablesOf(db)
389
- if (declared.every((table) => isPresent(after, table))) return 'present'
390
- throw error
391
- }
401
+ const present = declared.filter((table) => isPresent(existing, table))
402
+ throw new Error(
403
+ `The '${schema.name}' schema is not in this database: ${missing.map(displayName).join(', ')} missing` +
404
+ (present.length > 0
405
+ ? `, ${present.map(displayName).join(', ')} already there`
406
+ : '') +
407
+ '. The runtime does not create these — run `pikku db generate` to write the ' +
408
+ 'migration, then `pikku db migrate` to apply it.'
409
+ )
392
410
  }
393
411
 
394
412
  /**
@@ -101,6 +101,26 @@ export interface PikkuSchema {
101
101
  * database — or, worse, a stub table conjured up to make the error go away.
102
102
  */
103
103
  requires?: SchemaRequirement[]
104
+ /**
105
+ * The singleton services whose `init()` requires these tables, or absent for
106
+ * a schema every project needs.
107
+ *
108
+ * Named as services rather than as wirings because a service is what the
109
+ * tables actually belong to: one `requirePikkuSchema` call site per service,
110
+ * and `pikku db generate` writes the schema for a project whose functions
111
+ * reach any of them. A wiring is a poor proxy — a project can construct
112
+ * `KyselyWorkflowService` and wire no workflows at all.
113
+ *
114
+ * Absent means unconditional. `secrets` is on every project and the session
115
+ * and deployment stores are reached by generated code rather than by
116
+ * functions, so nothing a project writes implies them and their absence
117
+ * cannot be inferred.
118
+ *
119
+ * Under-naming is safe now that the runtime creates nothing: a schema left
120
+ * out of the migration is a sentence at startup, not a table conjured behind
121
+ * your back. Over-naming costs an unused table. Prefer over-naming.
122
+ */
123
+ ownedBy?: string[]
104
124
  }
105
125
 
106
126
  /**