@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,151 @@
1
+ import { DEFAULT_MAX_INTERVAL_MS, DEFAULT_MIN_INTERVAL_MS, } from '@pikku/core/virtual-user';
2
+ import { parseJson } from './kysely-json.js';
3
+ import { requirePikkuSchema } from './schema/index.js';
4
+ import { virtualUserScheduleSchema } from './schema/virtual-user-schedule.schema.js';
5
+ /**
6
+ * Keeps each persona's cadence in a `virtualUserSchedule` table.
7
+ *
8
+ * Wiring this is what turns virtual users from something a person triggers into
9
+ * something that happens: without it, {@link KyselyVirtualUserRunStore} still
10
+ * records every run, and every run still had someone behind it. Two services
11
+ * rather than one so that an app can want the history without wanting the bill.
12
+ *
13
+ * Reads and writes the same way the run store does — JSON as text, timestamps
14
+ * as ISO strings, flags as 0/1 — so a project on SQLite and one on Postgres
15
+ * hold the same bytes and `SerializePlugin` is optional on both.
16
+ */
17
+ export class KyselyVirtualUserScheduleStore {
18
+ db;
19
+ initialized = false;
20
+ constructor(db) {
21
+ this.db = db;
22
+ }
23
+ async init() {
24
+ if (this.initialized)
25
+ return;
26
+ await requirePikkuSchema(this.db, virtualUserScheduleSchema);
27
+ this.initialized = true;
28
+ }
29
+ async set(schedule) {
30
+ await this.init();
31
+ // Read-then-write rather than a partial upsert: a schedule is configuration
32
+ // written by a person, and the alternative is two dialects of "leave the
33
+ // columns I did not mention alone".
34
+ const existing = await this.get(schedule.persona);
35
+ const merged = {
36
+ persona: schedule.persona,
37
+ enabled: schedule.enabled ?? existing?.enabled ?? false,
38
+ disposition: schedule.disposition ?? existing?.disposition ?? 'realistic',
39
+ goals: [...(schedule.goals ?? existing?.goals ?? [])],
40
+ budget: schedule.budget === undefined
41
+ ? (existing?.budget ?? null)
42
+ : schedule.budget,
43
+ minIntervalMs: schedule.minIntervalMs ??
44
+ existing?.minIntervalMs ??
45
+ DEFAULT_MIN_INTERVAL_MS,
46
+ maxIntervalMs: schedule.maxIntervalMs ??
47
+ existing?.maxIntervalMs ??
48
+ DEFAULT_MAX_INTERVAL_MS,
49
+ // A new schedule is due immediately. Enabling one is a deliberate act, and
50
+ // waiting a random half-day to find out whether it works is not a feature.
51
+ nextRunAt: schedule.nextRunAt ?? existing?.nextRunAt ?? new Date(),
52
+ lastRunId: existing?.lastRunId ?? null,
53
+ lastRunAt: existing?.lastRunAt ?? null,
54
+ };
55
+ const values = {
56
+ persona: merged.persona,
57
+ enabled: merged.enabled ? 1 : 0,
58
+ disposition: merged.disposition,
59
+ goals: JSON.stringify(merged.goals),
60
+ budget: merged.budget ? JSON.stringify(merged.budget) : null,
61
+ minIntervalMs: String(merged.minIntervalMs),
62
+ maxIntervalMs: String(merged.maxIntervalMs),
63
+ nextRunAt: merged.nextRunAt.toISOString(),
64
+ };
65
+ await this.db
66
+ .insertInto('virtualUserSchedule')
67
+ .values(values)
68
+ .onConflict((oc) => oc.column('persona').doUpdateSet({
69
+ enabled: values.enabled,
70
+ disposition: values.disposition,
71
+ goals: values.goals,
72
+ budget: values.budget,
73
+ minIntervalMs: values.minIntervalMs,
74
+ maxIntervalMs: values.maxIntervalMs,
75
+ nextRunAt: values.nextRunAt,
76
+ }))
77
+ .execute();
78
+ return merged;
79
+ }
80
+ async get(persona) {
81
+ await this.init();
82
+ const row = await this.db
83
+ .selectFrom('virtualUserSchedule')
84
+ .selectAll()
85
+ .where('persona', '=', persona)
86
+ .executeTakeFirst();
87
+ return row ? this.toRecord(row) : null;
88
+ }
89
+ async list() {
90
+ await this.init();
91
+ const rows = await this.db
92
+ .selectFrom('virtualUserSchedule')
93
+ .selectAll()
94
+ .orderBy('persona', 'asc')
95
+ .execute();
96
+ return rows.map((row) => this.toRecord(row));
97
+ }
98
+ async due(now) {
99
+ await this.init();
100
+ const rows = await this.db
101
+ .selectFrom('virtualUserSchedule')
102
+ .selectAll()
103
+ .where('enabled', '=', 1)
104
+ .where('nextRunAt', '<=', now.toISOString())
105
+ .orderBy('nextRunAt', 'asc')
106
+ .execute();
107
+ return rows.map((row) => this.toRecord(row));
108
+ }
109
+ async claim(persona, claim) {
110
+ await this.init();
111
+ const result = await this.db
112
+ .updateTable('virtualUserSchedule')
113
+ .set({
114
+ nextRunAt: claim.nextRunAt.toISOString(),
115
+ // A claim with no run id is the one written before dispatch, and must
116
+ // not blank out which run this persona last managed to start.
117
+ ...(claim.runId
118
+ ? { lastRunId: claim.runId, lastRunAt: claim.at.toISOString() }
119
+ : {}),
120
+ })
121
+ .where('persona', '=', persona)
122
+ // The compare-and-set: whoever writes first moves `nextRunAt`, and every
123
+ // other tick holding the value they read matches nothing.
124
+ .where('nextRunAt', '=', claim.from.toISOString())
125
+ .executeTakeFirst();
126
+ return Number(result.numUpdatedRows ?? 0) > 0;
127
+ }
128
+ async remove(persona) {
129
+ await this.init();
130
+ await this.db
131
+ .deleteFrom('virtualUserSchedule')
132
+ .where('persona', '=', persona)
133
+ .execute();
134
+ }
135
+ toRecord(row) {
136
+ return {
137
+ persona: row.persona,
138
+ enabled: Boolean(row.enabled),
139
+ disposition: row.disposition,
140
+ goals: parseJson(row.goals) ?? [],
141
+ budget: row.budget
142
+ ? (parseJson(row.budget) ?? null)
143
+ : null,
144
+ minIntervalMs: Number(row.minIntervalMs),
145
+ maxIntervalMs: Number(row.maxIntervalMs),
146
+ nextRunAt: new Date(row.nextRunAt),
147
+ lastRunId: row.lastRunId,
148
+ lastRunAt: row.lastRunAt ? new Date(row.lastRunAt) : null,
149
+ };
150
+ }
151
+ }
@@ -1,6 +1,6 @@
1
1
  import { QueueWebhookService } from '@pikku/core/services';
2
2
  import { PIKKU_OUTGOING_WEBHOOK_QUEUE_NAME, } from '@pikku/core/services';
3
- import { ensurePikkuSchema } from './schema/index.js';
3
+ import { requirePikkuSchema } from './schema/index.js';
4
4
  import { webhookSchema } from './schema/webhook.schema.js';
5
5
  /**
6
6
  * Durable {@link QueueWebhookService}: still delivers through the
@@ -19,7 +19,7 @@ export class KyselyWebhookService extends QueueWebhookService {
19
19
  async init() {
20
20
  if (this.initialized)
21
21
  return;
22
- await ensurePikkuSchema(this.db, webhookSchema);
22
+ await requirePikkuSchema(this.db, webhookSchema);
23
23
  this.initialized = true;
24
24
  }
25
25
  async send(input) {
@@ -22,7 +22,7 @@ export declare class KyselyWorkflowMirror implements WorkflowRunMirror {
22
22
  * Create the underlying tables if none of them exist yet.
23
23
  *
24
24
  * Safe to call from either the mirror or `KyselyWorkflowService.init()` —
25
- * both apply the same `workflowSchema`, and `ensurePikkuSchema` is a no-op
25
+ * both apply the same `workflowSchema`, and `requirePikkuSchema` is a no-op
26
26
  * once the tables are there. A database holding only *part* of the schema
27
27
  * throws rather than filling in the rest: something else owns those tables,
28
28
  * and boot is not where that gets reconciled.
@@ -1,4 +1,4 @@
1
- import { ensurePikkuSchema } from './schema/index.js';
1
+ import { requirePikkuSchema } from './schema/index.js';
2
2
  import { workflowSchema } from './schema/workflow.schema.js';
3
3
  /**
4
4
  * Kysely-backed `WorkflowRunMirror`.
@@ -22,7 +22,7 @@ export class KyselyWorkflowMirror {
22
22
  * Create the underlying tables if none of them exist yet.
23
23
  *
24
24
  * Safe to call from either the mirror or `KyselyWorkflowService.init()` —
25
- * both apply the same `workflowSchema`, and `ensurePikkuSchema` is a no-op
25
+ * both apply the same `workflowSchema`, and `requirePikkuSchema` is a no-op
26
26
  * once the tables are there. A database holding only *part* of the schema
27
27
  * throws rather than filling in the rest: something else owns those tables,
28
28
  * and boot is not where that gets reconciled.
@@ -30,7 +30,7 @@ export class KyselyWorkflowMirror {
30
30
  async init() {
31
31
  if (this.initialized)
32
32
  return;
33
- await ensurePikkuSchema(this.db, workflowSchema);
33
+ await requirePikkuSchema(this.db, workflowSchema);
34
34
  this.initialized = true;
35
35
  }
36
36
  async createRun(runId, workflowName, input, inline, graphHash, wire, options) {
@@ -2,7 +2,7 @@ import { PikkuWorkflowService, WorkflowStepFunctionMismatchError, } from '@pikku
2
2
  import { sql } from 'kysely';
3
3
  import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js';
4
4
  import { parseJson } from './kysely-json.js';
5
- import { ensurePikkuSchema } from './schema/index.js';
5
+ import { requirePikkuSchema } from './schema/index.js';
6
6
  import { workflowSchema } from './schema/workflow.schema.js';
7
7
  export class KyselyWorkflowService extends PikkuWorkflowService {
8
8
  db;
@@ -16,7 +16,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
16
16
  async init() {
17
17
  if (this.initialized)
18
18
  return;
19
- await ensurePikkuSchema(this.db, workflowSchema);
19
+ await requirePikkuSchema(this.db, workflowSchema);
20
20
  this.initialized = true;
21
21
  }
22
22
  async createRunImpl(workflowName, input, inline, graphHash, wire, options) {
@@ -11,6 +11,7 @@ import { sql } from 'kysely';
11
11
  */
12
12
  export const agentSchema = {
13
13
  name: 'agent',
14
+ ownedBy: ['agentStorage', 'agentRunState', 'agentRunService'],
14
15
  statements: [
15
16
  (db) => db.schema
16
17
  .createTable('agentThreads')
@@ -2,10 +2,11 @@ import type { PikkuSchema } from './pikku-schema.types.js';
2
2
  /**
3
3
  * The `audit` table {@link KyselyAuditService} writes to and reads back.
4
4
  *
5
- * Deliberately not in `pikkuSchemas`: the runtime never needs this table, only
6
- * a project that has wired a durable audit sink does, and creating it for
7
- * everyone would put an empty table in every database. `KyselyAuditService.init()`
8
- * applies it, so it arrives exactly when the sink that fills it does.
5
+ * Gated by `ownedBy` rather than written for everyone: only a project that has
6
+ * wired a durable audit sink needs this table, and generating it for the rest
7
+ * would put an empty table in every database. `KyselyAuditService.init()`
8
+ * requires it and never creates it, so it arrives with the migration `pikku db
9
+ * generate` writes for the sink that fills it.
9
10
  *
10
11
  * Every column is text on every engine, matching the platform audit-queue
11
12
  * consumer's row shape — a locally-run project and a deployed stage write rows
@@ -1,10 +1,11 @@
1
1
  /**
2
2
  * The `audit` table {@link KyselyAuditService} writes to and reads back.
3
3
  *
4
- * Deliberately not in `pikkuSchemas`: the runtime never needs this table, only
5
- * a project that has wired a durable audit sink does, and creating it for
6
- * everyone would put an empty table in every database. `KyselyAuditService.init()`
7
- * applies it, so it arrives exactly when the sink that fills it does.
4
+ * Gated by `ownedBy` rather than written for everyone: only a project that has
5
+ * wired a durable audit sink needs this table, and generating it for the rest
6
+ * would put an empty table in every database. `KyselyAuditService.init()`
7
+ * requires it and never creates it, so it arrives with the migration `pikku db
8
+ * generate` writes for the sink that fills it.
8
9
  *
9
10
  * Every column is text on every engine, matching the platform audit-queue
10
11
  * consumer's row shape — a locally-run project and a deployed stage write rows
@@ -14,6 +15,7 @@
14
15
  */
15
16
  export const auditSchema = {
16
17
  name: 'audit',
18
+ ownedBy: ['audit', 'auditLog'],
17
19
  statements: [
18
20
  (db) => db.schema
19
21
  .createTable('audit')
@@ -2,6 +2,7 @@ import { sql } from 'kysely';
2
2
  /** Channels and their topic subscriptions, also read by the event-hub store. */
3
3
  export const channelSchema = {
4
4
  name: 'channel',
5
+ ownedBy: ['channelStore', 'eventHub'],
5
6
  statements: [
6
7
  (db) => db.schema
7
8
  .createTable('channels')
@@ -12,6 +12,7 @@ import { rawStatement } from './pikku-schema.types.js';
12
12
  */
13
13
  export const credentialSchema = {
14
14
  name: 'credential',
15
+ ownedBy: ['credentialService'],
15
16
  statements: [
16
17
  (db) => db.schema
17
18
  .createTable('credentials')
@@ -3,19 +3,43 @@ import { type PikkuSchema, type RequiredTypes, type SchemaRequirement } from './
3
3
  export type { PikkuSchema, RequiredTypes, SchemaContext, SchemaRequirement, SchemaStatement, SchemaStatementFactory, } from './pikku-schema.types.js';
4
4
  export { rawStatement, requiredType, schemaContext, unqualifiedContext, } from './pikku-schema.types.js';
5
5
  /**
6
- * Opt-in, so it is named here rather than folded into `pikkuSchemas` — the
7
- * runtime works without an audit table, and `KyselyAuditService.init()` applies
8
- * this for the projects that wire the sink.
9
- */
10
- export { auditSchema } from './audit.schema.js';
11
- /**
12
- * Every table the pikku runtime needs, in dependency order.
6
+ * Every table any part of the pikku runtime can need, in dependency order.
13
7
  *
14
8
  * Order is load-bearing, not cosmetic: `scope` has foreign keys onto Better
15
9
  * Auth's `user`, and the tables within each schema reference each other. Sort
16
10
  * this list alphabetically and it stops applying.
11
+ *
12
+ * `audit` and `virtualUser` are in the list now. They used to be left out and
13
+ * created by their own services at boot, which was the only way they ever came
14
+ * into existence — so with the runtime no longer creating anything, leaving
15
+ * them out would mean a wired audit sink starting up against a table that
16
+ * nothing writes a migration for. Both are gated by `ownedBy`, so a project
17
+ * that wires neither still carries neither.
17
18
  */
18
19
  export declare const pikkuSchemas: PikkuSchema[];
20
+ export { agentSchema } from './agent.schema.js';
21
+ export { auditSchema } from './audit.schema.js';
22
+ export { channelSchema } from './channel.schema.js';
23
+ export { credentialSchema } from './credential.schema.js';
24
+ export { deploymentSchema } from './deployment.schema.js';
25
+ export { scopeSchema } from './scope.schema.js';
26
+ export { secretSchema } from './secret.schema.js';
27
+ export { sessionSchema } from './session.schema.js';
28
+ export { virtualUserSchema } from './virtual-user.schema.js';
29
+ export { virtualUserScheduleSchema } from './virtual-user-schedule.schema.js';
30
+ export { webhookSchema } from './webhook.schema.js';
31
+ export { workflowSchema } from './workflow.schema.js';
32
+ /**
33
+ * The schemas a project needs, given the services its code actually reaches.
34
+ *
35
+ * `services` is the inspector's `requiredServices` — the same set that drives
36
+ * service tree-shaking, so a schema is written for exactly the projects whose
37
+ * functions, middleware or wirings reach the service that owns its tables.
38
+ *
39
+ * The gate is one-sided: a schema declaring no `ownedBy` is always in, because
40
+ * nothing a project writes implies it and leaving it out would be a guess.
41
+ */
42
+ export declare const requiredPikkuSchemas: (services: ReadonlySet<string>) => PikkuSchema[];
19
43
  export interface UnmetRequirement {
20
44
  schema: PikkuSchema;
21
45
  requirement: SchemaRequirement;
@@ -74,27 +98,25 @@ export interface DeclaredTable {
74
98
  * schema the DDL will actually target — the same binding the lookup has to use.
75
99
  */
76
100
  export declare const declaredTables: (schema: PikkuSchema, db: Kysely<any>) => DeclaredTable[];
77
- /** What `ensurePikkuSchema` found when it looked. */
78
- export type EnsureOutcome = 'present' | 'created';
79
101
  /**
80
- * Make sure one schema's tables are there, creating them only if none are.
81
- *
82
- * What a service calls at boot. It looks before it creates, which is the whole
83
- * difference from the `.ifNotExists()` DDL this replaces: that spelling turned
84
- * every failure into a silent no-op, and hid a foreign key onto `user.id` that
85
- * postgres had been rejecting since the day it was written.
86
- *
87
- * A partially-present schema is refused rather than completed. Half a schema
88
- * means something else already applied part of it — a migration, an older
89
- * version, a hand-run script — and creating the remainder at boot would leave
90
- * two authorities over one set of tables, which is the condition all of this
91
- * exists to end.
92
- *
93
- * Creating at boot is the fallback, not the intent. `pikku db generate` writes
94
- * the declaration down as a migration; a project that has done so takes the
95
- * `present` path and this never issues DDL at all.
102
+ * Refuse to start unless one schema's tables are already there.
103
+ *
104
+ * What a service calls at boot, and it never issues DDL. The runtime is not an
105
+ * author of the schema: `pikku db generate` writes the declaration down as a
106
+ * migration and `pikku db migrate` applies it, and those two are the only way
107
+ * these tables come into existence. A service that finds them missing says so
108
+ * and stops.
109
+ *
110
+ * This is the whole point of gating what `db generate` writes. While boot could
111
+ * still create, a schema that generation left out was created silently at
112
+ * startup instead — two authorities over one set of tables, which is the
113
+ * condition all of this exists to end. With creation gone, a schema generation
114
+ * missed is a sentence at startup naming the command that fixes it.
115
+ *
116
+ * Half-present is not a distinct case any more. Every missing table is reported
117
+ * the same way, because the remedy is the same one either way.
96
118
  */
97
- export declare const ensurePikkuSchema: (db: Kysely<any>, schema: PikkuSchema) => Promise<EnsureOutcome>;
119
+ export declare const requirePikkuSchema: (db: Kysely<any>, schema: PikkuSchema) => Promise<void>;
98
120
  /**
99
121
  * Render the declaration as SQL for the dialect `db` was built with.
100
122
  *
@@ -1,6 +1,7 @@
1
1
  import { CamelCasePlugin } from 'kysely';
2
2
  import { schemaContext, } from './pikku-schema.types.js';
3
3
  import { agentSchema } from './agent.schema.js';
4
+ import { auditSchema } from './audit.schema.js';
4
5
  import { channelSchema } from './channel.schema.js';
5
6
  import { credentialSchema } from './credential.schema.js';
6
7
  import { deploymentSchema } from './deployment.schema.js';
@@ -9,19 +10,22 @@ import { secretSchema } from './secret.schema.js';
9
10
  import { sessionSchema } from './session.schema.js';
10
11
  import { webhookSchema } from './webhook.schema.js';
11
12
  import { workflowSchema } from './workflow.schema.js';
13
+ import { virtualUserSchema } from './virtual-user.schema.js';
14
+ import { virtualUserScheduleSchema } from './virtual-user-schedule.schema.js';
12
15
  export { rawStatement, requiredType, schemaContext, unqualifiedContext, } from './pikku-schema.types.js';
13
16
  /**
14
- * Opt-in, so it is named here rather than folded into `pikkuSchemas` — the
15
- * runtime works without an audit table, and `KyselyAuditService.init()` applies
16
- * this for the projects that wire the sink.
17
- */
18
- export { auditSchema } from './audit.schema.js';
19
- /**
20
- * Every table the pikku runtime needs, in dependency order.
17
+ * Every table any part of the pikku runtime can need, in dependency order.
21
18
  *
22
19
  * Order is load-bearing, not cosmetic: `scope` has foreign keys onto Better
23
20
  * Auth's `user`, and the tables within each schema reference each other. Sort
24
21
  * this list alphabetically and it stops applying.
22
+ *
23
+ * `audit` and `virtualUser` are in the list now. They used to be left out and
24
+ * created by their own services at boot, which was the only way they ever came
25
+ * into existence — so with the runtime no longer creating anything, leaving
26
+ * them out would mean a wired audit sink starting up against a table that
27
+ * nothing writes a migration for. Both are gated by `ownedBy`, so a project
28
+ * that wires neither still carries neither.
25
29
  */
26
30
  export const pikkuSchemas = [
27
31
  channelSchema,
@@ -33,7 +37,33 @@ export const pikkuSchemas = [
33
37
  workflowSchema,
34
38
  agentSchema,
35
39
  scopeSchema,
40
+ auditSchema,
41
+ virtualUserSchema,
42
+ virtualUserScheduleSchema,
36
43
  ];
44
+ export { agentSchema } from './agent.schema.js';
45
+ export { auditSchema } from './audit.schema.js';
46
+ export { channelSchema } from './channel.schema.js';
47
+ export { credentialSchema } from './credential.schema.js';
48
+ export { deploymentSchema } from './deployment.schema.js';
49
+ export { scopeSchema } from './scope.schema.js';
50
+ export { secretSchema } from './secret.schema.js';
51
+ export { sessionSchema } from './session.schema.js';
52
+ export { virtualUserSchema } from './virtual-user.schema.js';
53
+ export { virtualUserScheduleSchema } from './virtual-user-schedule.schema.js';
54
+ export { webhookSchema } from './webhook.schema.js';
55
+ export { workflowSchema } from './workflow.schema.js';
56
+ /**
57
+ * The schemas a project needs, given the services its code actually reaches.
58
+ *
59
+ * `services` is the inspector's `requiredServices` — the same set that drives
60
+ * service tree-shaking, so a schema is written for exactly the projects whose
61
+ * functions, middleware or wirings reach the service that owns its tables.
62
+ *
63
+ * The gate is one-sided: a schema declaring no `ownedBy` is always in, because
64
+ * nothing a project writes implies it and leaving it out would be a guess.
65
+ */
66
+ export const requiredPikkuSchemas = (services) => pikkuSchemas.filter((schema) => !schema.ownedBy || schema.ownedBy.some((name) => services.has(name)));
37
67
  /**
38
68
  * Bind a declaration to a database.
39
69
  *
@@ -237,53 +267,36 @@ const isPresent = (existing, table) => table.schema && existing.schemaAware
237
267
  ? existing.qualified.has(`${table.schema}.${table.name}`)
238
268
  : existing.bare.has(table.name);
239
269
  /**
240
- * Make sure one schema's tables are there, creating them only if none are.
270
+ * Refuse to start unless one schema's tables are already there.
241
271
  *
242
- * What a service calls at boot. It looks before it creates, which is the whole
243
- * difference from the `.ifNotExists()` DDL this replaces: that spelling turned
244
- * every failure into a silent no-op, and hid a foreign key onto `user.id` that
245
- * postgres had been rejecting since the day it was written.
272
+ * What a service calls at boot, and it never issues DDL. The runtime is not an
273
+ * author of the schema: `pikku db generate` writes the declaration down as a
274
+ * migration and `pikku db migrate` applies it, and those two are the only way
275
+ * these tables come into existence. A service that finds them missing says so
276
+ * and stops.
246
277
  *
247
- * A partially-present schema is refused rather than completed. Half a schema
248
- * means something else already applied part of it — a migration, an older
249
- * version, a hand-run script — and creating the remainder at boot would leave
250
- * two authorities over one set of tables, which is the condition all of this
251
- * exists to end.
278
+ * This is the whole point of gating what `db generate` writes. While boot could
279
+ * still create, a schema that generation left out was created silently at
280
+ * startup instead — two authorities over one set of tables, which is the
281
+ * condition all of this exists to end. With creation gone, a schema generation
282
+ * missed is a sentence at startup naming the command that fixes it.
252
283
  *
253
- * Creating at boot is the fallback, not the intent. `pikku db generate` writes
254
- * the declaration down as a migration; a project that has done so takes the
255
- * `present` path and this never issues DDL at all.
284
+ * Half-present is not a distinct case any more. Every missing table is reported
285
+ * the same way, because the remedy is the same one either way.
256
286
  */
257
- export const ensurePikkuSchema = async (db, schema) => {
287
+ export const requirePikkuSchema = async (db, schema) => {
258
288
  const existing = await tablesOf(db);
259
289
  const declared = declaredTables(schema, db);
260
290
  const missing = declared.filter((table) => !isPresent(existing, table));
261
291
  if (missing.length === 0)
262
- return 'present';
263
- if (missing.length < declared.length) {
264
- throw new Error(`The '${schema.name}' schema is half applied: ${missing.map(displayName).join(', ')} missing, ` +
265
- `${declared
266
- .filter((table) => isPresent(existing, table))
267
- .map(displayName)
268
- .join(', ')} already there. ` +
269
- 'Something else owns part of it. Reconcile it with a migration — ' +
270
- '`pikku db generate` writes the declaration down — rather than creating the rest at boot.');
271
- }
272
- try {
273
- await applyPikkuSchemas(db, [schema]);
274
- return 'created';
275
- }
276
- catch (error) {
277
- // Two instances booting cold against one database can both see it empty
278
- // before either has finished creating. If the other one got there, say so;
279
- // anything else is a real failure and still throws. This is a courtesy, not
280
- // a guarantee — a boot race is only fully answered by having run the
281
- // migration, which is what `pikku db generate` is for.
282
- const after = await tablesOf(db);
283
- if (declared.every((table) => isPresent(after, table)))
284
- return 'present';
285
- throw error;
286
- }
292
+ return;
293
+ const present = declared.filter((table) => isPresent(existing, table));
294
+ throw new Error(`The '${schema.name}' schema is not in this database: ${missing.map(displayName).join(', ')} missing` +
295
+ (present.length > 0
296
+ ? `, ${present.map(displayName).join(', ')} already there`
297
+ : '') +
298
+ '. The runtime does not create these — run `pikku db generate` to write the ' +
299
+ 'migration, then `pikku db migrate` to apply it.');
287
300
  };
288
301
  /**
289
302
  * Render the declaration as SQL for the dialect `db` was built with.
@@ -80,6 +80,26 @@ export interface PikkuSchema {
80
80
  * database — or, worse, a stub table conjured up to make the error go away.
81
81
  */
82
82
  requires?: SchemaRequirement[];
83
+ /**
84
+ * The singleton services whose `init()` requires these tables, or absent for
85
+ * a schema every project needs.
86
+ *
87
+ * Named as services rather than as wirings because a service is what the
88
+ * tables actually belong to: one `requirePikkuSchema` call site per service,
89
+ * and `pikku db generate` writes the schema for a project whose functions
90
+ * reach any of them. A wiring is a poor proxy — a project can construct
91
+ * `KyselyWorkflowService` and wire no workflows at all.
92
+ *
93
+ * Absent means unconditional. `secrets` is on every project and the session
94
+ * and deployment stores are reached by generated code rather than by
95
+ * functions, so nothing a project writes implies them and their absence
96
+ * cannot be inferred.
97
+ *
98
+ * Under-naming is safe now that the runtime creates nothing: a schema left
99
+ * out of the migration is a sentence at startup, not a table conjured behind
100
+ * your back. Over-naming costs an unused table. Prefer over-naming.
101
+ */
102
+ ownedBy?: string[];
83
103
  }
84
104
  /**
85
105
  * A column another source owns, which one of these schemas depends on.
@@ -18,6 +18,7 @@ import { requiredType } from './pikku-schema.types.js';
18
18
  */
19
19
  export const scopeSchema = {
20
20
  name: 'scope',
21
+ ownedBy: ['scopeService'],
21
22
  requires: [{ table: 'user', column: 'id', owner: 'Better Auth' }],
22
23
  statements: [
23
24
  (db) => db.schema
@@ -0,0 +1,15 @@
1
+ import type { PikkuSchema } from './pikku-schema.types.js';
2
+ /**
3
+ * The `virtualUserSchedule` table {@link KyselyVirtualUserScheduleStore} writes
4
+ * to: one row per persona that keeps using the app on its own.
5
+ *
6
+ * Its own schema rather than a third table in {@link virtualUserSchema}, and
7
+ * owned by its own store: recording what a persona did and deciding that it
8
+ * keeps going on its own are separate opt-ins, and a project that only wants
9
+ * the first should not be handed a cadence table it never writes to.
10
+ *
11
+ * `persona` is the primary key: a persona has one cadence, and a second row for
12
+ * the same one would mean two copies of the same user acting at once — not a
13
+ * heavier test, a different and unreproducible one.
14
+ */
15
+ export declare const virtualUserScheduleSchema: PikkuSchema;
@@ -0,0 +1,43 @@
1
+ /**
2
+ * The `virtualUserSchedule` table {@link KyselyVirtualUserScheduleStore} writes
3
+ * to: one row per persona that keeps using the app on its own.
4
+ *
5
+ * Its own schema rather than a third table in {@link virtualUserSchema}, and
6
+ * owned by its own store: recording what a persona did and deciding that it
7
+ * keeps going on its own are separate opt-ins, and a project that only wants
8
+ * the first should not be handed a cadence table it never writes to.
9
+ *
10
+ * `persona` is the primary key: a persona has one cadence, and a second row for
11
+ * the same one would mean two copies of the same user acting at once — not a
12
+ * heavier test, a different and unreproducible one.
13
+ */
14
+ export const virtualUserScheduleSchema = {
15
+ name: 'virtual-user-schedule',
16
+ ownedBy: ['virtualUserScheduleStore'],
17
+ statements: [
18
+ (db) => db.schema
19
+ .createTable('virtualUserSchedule')
20
+ .addColumn('persona', 'varchar(255)', (col) => col.primaryKey())
21
+ // 0 or 1 rather than a boolean, matching the run tables: a bare sqlite
22
+ // driver cannot bind one, and `SerializePlugin` is not installed
23
+ // everywhere. Off by default, so creating the table spends nothing.
24
+ .addColumn('enabled', 'integer', (col) => col.defaultTo(0).notNull())
25
+ .addColumn('disposition', 'varchar(50)', (col) => col.notNull())
26
+ .addColumn('goals', 'text', (col) => col.defaultTo('[]').notNull())
27
+ .addColumn('budget', 'text')
28
+ // The gap to the next run is drawn between these rather than fixed, so
29
+ // the persona does not keep an appointment.
30
+ .addColumn('minIntervalMs', 'bigint', (col) => col.notNull())
31
+ .addColumn('maxIntervalMs', 'bigint', (col) => col.notNull())
32
+ // The entire schedule. Written before a run is dispatched, so a tick
33
+ // that dies halfway cannot leave the row due for the next one.
34
+ .addColumn('nextRunAt', 'timestamp', (col) => col.notNull())
35
+ .addColumn('lastRunId', 'varchar(36)')
36
+ .addColumn('lastRunAt', 'timestamp'),
37
+ // The only read that matters at speed: which rows are due, on every tick.
38
+ (db) => db.schema
39
+ .createIndex('idx_virtual_user_schedule_due')
40
+ .on('virtualUserSchedule')
41
+ .columns(['enabled', 'nextRunAt']),
42
+ ],
43
+ };