@pikku/kysely 0.13.19 → 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 (65) hide show
  1. package/CHANGELOG.md +54 -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.js +2 -2
  19. package/dist/src/schema/agent.schema.js +1 -0
  20. package/dist/src/schema/audit.schema.d.ts +5 -4
  21. package/dist/src/schema/audit.schema.js +6 -4
  22. package/dist/src/schema/channel.schema.js +1 -0
  23. package/dist/src/schema/credential.schema.js +1 -0
  24. package/dist/src/schema/index.d.ts +47 -26
  25. package/dist/src/schema/index.js +56 -46
  26. package/dist/src/schema/pikku-schema.types.d.ts +20 -0
  27. package/dist/src/schema/scope.schema.js +1 -0
  28. package/dist/src/schema/virtual-user.schema.js +2 -1
  29. package/dist/src/schema/webhook.schema.js +1 -0
  30. package/dist/src/schema/workflow.schema.js +1 -0
  31. package/package.json +2 -2
  32. package/src/index.ts +12 -2
  33. package/src/kysely-agent-run-state-service.ts +2 -2
  34. package/src/kysely-agent-storage-service.ts +2 -2
  35. package/src/kysely-audit-service.test.ts +2 -10
  36. package/src/kysely-audit-service.ts +2 -2
  37. package/src/kysely-channel-store.ts +2 -2
  38. package/src/kysely-credential-service.ts +2 -2
  39. package/src/kysely-deployment-service.ts +2 -2
  40. package/src/kysely-scope-service.test.ts +2 -0
  41. package/src/kysely-scope-service.ts +2 -2
  42. package/src/kysely-secret-service.ts +2 -2
  43. package/src/kysely-services.test.ts +26 -0
  44. package/src/kysely-session-store.ts +2 -2
  45. package/src/kysely-tables.test.ts +1 -12
  46. package/src/kysely-tables.ts +29 -0
  47. package/src/kysely-virtual-user-run-store.test.ts +22 -6
  48. package/src/kysely-virtual-user-run-store.ts +2 -2
  49. package/src/kysely-webhook-service.test.ts +4 -0
  50. package/src/kysely-webhook-service.ts +2 -2
  51. package/src/kysely-workflow-mirror.test.ts +2 -0
  52. package/src/kysely-workflow-mirror.ts +3 -3
  53. package/src/kysely-workflow-service.test.ts +2 -0
  54. package/src/kysely-workflow-service.ts +2 -2
  55. package/src/schema/agent.schema.ts +1 -0
  56. package/src/schema/audit.schema.ts +6 -4
  57. package/src/schema/channel.schema.ts +1 -0
  58. package/src/schema/credential.schema.ts +1 -0
  59. package/src/schema/index.ts +67 -52
  60. package/src/schema/pikku-schema.types.ts +20 -0
  61. package/src/schema/schema.test.ts +86 -29
  62. package/src/schema/scope.schema.ts +1 -0
  63. package/src/schema/virtual-user.schema.ts +2 -1
  64. package/src/schema/webhook.schema.ts +1 -0
  65. package/src/schema/workflow.schema.ts +1 -0
@@ -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,21 @@ 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';
12
14
  export { rawStatement, requiredType, schemaContext, unqualifiedContext, } from './pikku-schema.types.js';
13
15
  /**
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.
16
+ * Every table any part of the pikku runtime can need, in dependency order.
21
17
  *
22
18
  * Order is load-bearing, not cosmetic: `scope` has foreign keys onto Better
23
19
  * Auth's `user`, and the tables within each schema reference each other. Sort
24
20
  * this list alphabetically and it stops applying.
21
+ *
22
+ * `audit` and `virtualUser` are in the list now. They used to be left out and
23
+ * created by their own services at boot, which was the only way they ever came
24
+ * into existence — so with the runtime no longer creating anything, leaving
25
+ * them out would mean a wired audit sink starting up against a table that
26
+ * nothing writes a migration for. Both are gated by `ownedBy`, so a project
27
+ * that wires neither still carries neither.
25
28
  */
26
29
  export const pikkuSchemas = [
27
30
  channelSchema,
@@ -33,7 +36,31 @@ export const pikkuSchemas = [
33
36
  workflowSchema,
34
37
  agentSchema,
35
38
  scopeSchema,
39
+ auditSchema,
40
+ virtualUserSchema,
36
41
  ];
42
+ export { agentSchema } from './agent.schema.js';
43
+ export { auditSchema } from './audit.schema.js';
44
+ export { channelSchema } from './channel.schema.js';
45
+ export { credentialSchema } from './credential.schema.js';
46
+ export { deploymentSchema } from './deployment.schema.js';
47
+ export { scopeSchema } from './scope.schema.js';
48
+ export { secretSchema } from './secret.schema.js';
49
+ export { sessionSchema } from './session.schema.js';
50
+ export { virtualUserSchema } from './virtual-user.schema.js';
51
+ export { webhookSchema } from './webhook.schema.js';
52
+ export { workflowSchema } from './workflow.schema.js';
53
+ /**
54
+ * The schemas a project needs, given the services its code actually reaches.
55
+ *
56
+ * `services` is the inspector's `requiredServices` — the same set that drives
57
+ * service tree-shaking, so a schema is written for exactly the projects whose
58
+ * functions, middleware or wirings reach the service that owns its tables.
59
+ *
60
+ * The gate is one-sided: a schema declaring no `ownedBy` is always in, because
61
+ * nothing a project writes implies it and leaving it out would be a guess.
62
+ */
63
+ export const requiredPikkuSchemas = (services) => pikkuSchemas.filter((schema) => !schema.ownedBy || schema.ownedBy.some((name) => services.has(name)));
37
64
  /**
38
65
  * Bind a declaration to a database.
39
66
  *
@@ -237,53 +264,36 @@ const isPresent = (existing, table) => table.schema && existing.schemaAware
237
264
  ? existing.qualified.has(`${table.schema}.${table.name}`)
238
265
  : existing.bare.has(table.name);
239
266
  /**
240
- * Make sure one schema's tables are there, creating them only if none are.
267
+ * Refuse to start unless one schema's tables are already there.
241
268
  *
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.
269
+ * What a service calls at boot, and it never issues DDL. The runtime is not an
270
+ * author of the schema: `pikku db generate` writes the declaration down as a
271
+ * migration and `pikku db migrate` applies it, and those two are the only way
272
+ * these tables come into existence. A service that finds them missing says so
273
+ * and stops.
246
274
  *
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.
275
+ * This is the whole point of gating what `db generate` writes. While boot could
276
+ * still create, a schema that generation left out was created silently at
277
+ * startup instead — two authorities over one set of tables, which is the
278
+ * condition all of this exists to end. With creation gone, a schema generation
279
+ * missed is a sentence at startup naming the command that fixes it.
252
280
  *
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.
281
+ * Half-present is not a distinct case any more. Every missing table is reported
282
+ * the same way, because the remedy is the same one either way.
256
283
  */
257
- export const ensurePikkuSchema = async (db, schema) => {
284
+ export const requirePikkuSchema = async (db, schema) => {
258
285
  const existing = await tablesOf(db);
259
286
  const declared = declaredTables(schema, db);
260
287
  const missing = declared.filter((table) => !isPresent(existing, table));
261
288
  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
- }
289
+ return;
290
+ const present = declared.filter((table) => isPresent(existing, table));
291
+ throw new Error(`The '${schema.name}' schema is not in this database: ${missing.map(displayName).join(', ')} missing` +
292
+ (present.length > 0
293
+ ? `, ${present.map(displayName).join(', ')} already there`
294
+ : '') +
295
+ '. The runtime does not create these — run `pikku db generate` to write the ' +
296
+ 'migration, then `pikku db migrate` to apply it.');
287
297
  };
288
298
  /**
289
299
  * 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
@@ -17,7 +17,8 @@ import { sql } from 'kysely';
17
17
  * week's does not depend on which database they were found in.
18
18
  */
19
19
  export const virtualUserSchema = {
20
- name: 'virtualUser',
20
+ name: 'virtual-user',
21
+ ownedBy: ['virtualUserRunStore'],
21
22
  statements: [
22
23
  (db) => db.schema
23
24
  .createTable('virtualUserRun')
@@ -2,6 +2,7 @@ import { sql } from 'kysely';
2
2
  /** Webhook deliveries and one row per delivery attempt. */
3
3
  export const webhookSchema = {
4
4
  name: 'webhook',
5
+ ownedBy: ['webhookService'],
5
6
  statements: [
6
7
  (db) => db.schema
7
8
  .createTable('webhookDelivery')
@@ -16,6 +16,7 @@ import { sql } from 'kysely';
16
16
  */
17
17
  export const workflowSchema = {
18
18
  name: 'workflow',
19
+ ownedBy: ['workflowService', 'workflowRunService'],
19
20
  statements: [
20
21
  (db) => db.schema
21
22
  .createTable('workflowRuns')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/kysely",
3
- "version": "0.13.19",
3
+ "version": "0.13.20",
4
4
  "description": "Kysely-backed Pikku services — workflow state, secrets, sessions, scopes and audit",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -21,7 +21,7 @@
21
21
  "prepublishOnly": "yarn build"
22
22
  },
23
23
  "peerDependencies": {
24
- "@pikku/core": "^0.12.87"
24
+ "@pikku/core": "^0.12.89"
25
25
  },
26
26
  "dependencies": {
27
27
  "kysely": "^0.29.5"
package/src/index.ts CHANGED
@@ -32,12 +32,22 @@ export {
32
32
  type CreateCoercionPluginOptions,
33
33
  } from './coercion-plugin.js'
34
34
  export {
35
+ agentSchema,
35
36
  auditSchema,
37
+ channelSchema,
38
+ credentialSchema,
39
+ deploymentSchema,
40
+ scopeSchema,
41
+ secretSchema,
42
+ sessionSchema,
43
+ virtualUserSchema,
44
+ webhookSchema,
45
+ workflowSchema,
36
46
  pikkuSchemas,
47
+ requiredPikkuSchemas,
37
48
  applyPikkuSchemas,
38
49
  compilePikkuSchemas,
39
- ensurePikkuSchema,
40
- type EnsureOutcome,
50
+ requirePikkuSchema,
41
51
  resolveRequirements,
42
52
  type PikkuSchema,
43
53
  type RequiredTypes,
@@ -9,7 +9,7 @@ import type {
9
9
  SaveScoreInput,
10
10
  } from '@pikku/core/services'
11
11
  import type { PendingApproval } from '@pikku/core/agent'
12
- import { ensurePikkuSchema } from './schema/index.js'
12
+ import { requirePikkuSchema } from './schema/index.js'
13
13
  import { agentSchema } from './schema/agent.schema.js'
14
14
  import { getRunScores, saveRunScore } from './kysely-agent-run-scores.js'
15
15
 
@@ -20,7 +20,7 @@ export class KyselyAgentRunStateService implements AgentRunStateService {
20
20
 
21
21
  async init(): Promise<void> {
22
22
  if (this.initialized) return
23
- await ensurePikkuSchema(this.db, agentSchema)
23
+ await requirePikkuSchema(this.db, agentSchema)
24
24
  this.initialized = true
25
25
  }
26
26
 
@@ -14,7 +14,7 @@ import type { Kysely } from 'kysely'
14
14
  import type { KyselyPikkuDB } from './kysely-tables.js'
15
15
  import { parseJson } from './kysely-json.js'
16
16
  import { getRunScores, saveRunScore } from './kysely-agent-run-scores.js'
17
- import { ensurePikkuSchema } from './schema/index.js'
17
+ import { requirePikkuSchema } from './schema/index.js'
18
18
  import { agentSchema } from './schema/agent.schema.js'
19
19
 
20
20
  export class KyselyAgentStorageService
@@ -26,7 +26,7 @@ export class KyselyAgentStorageService
26
26
 
27
27
  public async init(): Promise<void> {
28
28
  if (this.initialized) return
29
- await ensurePikkuSchema(this.db, agentSchema)
29
+ await requirePikkuSchema(this.db, agentSchema)
30
30
  this.initialized = true
31
31
  }
32
32
 
@@ -309,21 +309,13 @@ describe('KyselyAuditService against a CamelCasePlugin connection', () => {
309
309
  })
310
310
 
311
311
  describe('KyselyAuditService.init', () => {
312
- test('creates the audit table on a database that has none', async () => {
312
+ test('refuses to boot against a database that has none', async () => {
313
313
  const fresh = new Kysely<any>({
314
314
  dialect: new SqliteDialect({ database: new Database(':memory:') }),
315
315
  plugins: [new CamelCasePlugin()],
316
316
  })
317
317
  const freshService = new KyselyAuditService(fresh)
318
- await freshService.init()
319
- await freshService.write([
320
- event({ eventId: 'e1', type: 't', occurredAt: '2026-01-01' }),
321
- ])
322
- const { events } = await freshService.query()
323
- assert.deepEqual(
324
- events.map((e) => e.eventId),
325
- ['e1']
326
- )
318
+ await assert.rejects(freshService.init(), /pikku db generate/)
327
319
  })
328
320
 
329
321
  // Two boots against one database, and the second must not wipe the first.
@@ -6,7 +6,7 @@ import type {
6
6
  AuditQueryResult,
7
7
  } from '@pikku/core/services'
8
8
  import type { Kysely } from 'kysely'
9
- import { ensurePikkuSchema } from './schema/index.js'
9
+ import { requirePikkuSchema } from './schema/index.js'
10
10
  import { auditSchema } from './schema/audit.schema.js'
11
11
 
12
12
  const jsonOrNull = (v: unknown): string | null =>
@@ -103,7 +103,7 @@ export class KyselyAuditService implements AuditService {
103
103
  */
104
104
  public async init(): Promise<void> {
105
105
  if (this.initialized) return
106
- await ensurePikkuSchema(this.db, auditSchema)
106
+ await requirePikkuSchema(this.db, auditSchema)
107
107
  this.initialized = true
108
108
  }
109
109
 
@@ -3,7 +3,7 @@ import type { Channel } from '@pikku/core/channel'
3
3
  import type { Kysely } from 'kysely'
4
4
  import type { KyselyPikkuDB } from './kysely-tables.js'
5
5
  import { parseJson } from './kysely-json.js'
6
- import { ensurePikkuSchema } from './schema/index.js'
6
+ import { requirePikkuSchema } from './schema/index.js'
7
7
  import { channelSchema } from './schema/channel.schema.js'
8
8
 
9
9
  export class KyselyChannelStore extends ChannelStore {
@@ -15,7 +15,7 @@ export class KyselyChannelStore extends ChannelStore {
15
15
 
16
16
  public async init(): Promise<void> {
17
17
  if (this.initialized) return
18
- await ensurePikkuSchema(this.db, channelSchema)
18
+ await requirePikkuSchema(this.db, channelSchema)
19
19
  this.initialized = true
20
20
  }
21
21
 
@@ -8,7 +8,7 @@ import {
8
8
  import type { CredentialService } from '@pikku/core/services'
9
9
  import type { Kysely } from 'kysely'
10
10
  import type { KyselyPikkuDB } from './kysely-tables.js'
11
- import { ensurePikkuSchema } from './schema/index.js'
11
+ import { requirePikkuSchema } from './schema/index.js'
12
12
  import { credentialSchema } from './schema/credential.schema.js'
13
13
 
14
14
  export interface KyselyCredentialServiceConfig {
@@ -42,7 +42,7 @@ export class KyselyCredentialService implements CredentialService {
42
42
 
43
43
  public async init(): Promise<void> {
44
44
  if (this.initialized) return
45
- await ensurePikkuSchema(this.db, credentialSchema)
45
+ await requirePikkuSchema(this.db, credentialSchema)
46
46
  this.initialized = true
47
47
  }
48
48
 
@@ -9,7 +9,7 @@ import type { SecretService } from '@pikku/core/services'
9
9
  import { getAllFunctionNames } from '@pikku/core/function'
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 { deploymentSchema } from './schema/deployment.schema.js'
14
14
 
15
15
  export class KyselyDeploymentService implements DeploymentService {
@@ -31,7 +31,7 @@ export class KyselyDeploymentService implements DeploymentService {
31
31
 
32
32
  public async init(): Promise<void> {
33
33
  if (this.initialized) return
34
- await ensurePikkuSchema(this.db, deploymentSchema)
34
+ await requirePikkuSchema(this.db, deploymentSchema)
35
35
  this.initialized = true
36
36
  }
37
37
 
@@ -5,6 +5,7 @@ import Database from 'better-sqlite3'
5
5
  import { SerializePlugin } from './serialize-plugin.js'
6
6
  import type { KyselyPikkuDB } from './kysely-tables.js'
7
7
  import { KyselyScopeService } from './kysely-scope-service.js'
8
+ import { applyPikkuSchemas, scopeSchema } from './schema/index.js'
8
9
 
9
10
  const SCOPES = [
10
11
  { id: 'admin' },
@@ -41,6 +42,7 @@ beforeEach(async () => {
41
42
  plugins: [new CamelCasePlugin(), new SerializePlugin()],
42
43
  })
43
44
  await createUserTable(db)
45
+ await applyPikkuSchemas(db, [scopeSchema])
44
46
  service = new KyselyScopeService(db)
45
47
  await service.init()
46
48
  await service.syncScopes(SCOPES)
@@ -8,7 +8,7 @@ import {
8
8
  } from '@pikku/core/errors'
9
9
  import type { Kysely } from 'kysely'
10
10
  import type { KyselyPikkuDB } from './kysely-tables.js'
11
- import { ensurePikkuSchema } from './schema/index.js'
11
+ import { requirePikkuSchema } from './schema/index.js'
12
12
  import { scopeSchema } from './schema/scope.schema.js'
13
13
 
14
14
  /**
@@ -30,7 +30,7 @@ export class KyselyScopeService implements ScopeService {
30
30
 
31
31
  public async init(): Promise<void> {
32
32
  if (this.initialized) return
33
- await ensurePikkuSchema(this.db, scopeSchema)
33
+ await requirePikkuSchema(this.db, scopeSchema)
34
34
  this.initialized = true
35
35
  }
36
36
 
@@ -10,7 +10,7 @@ import type { SecretService, SecretValues } from '@pikku/core/services'
10
10
  import type { SecretValue } from '@pikku/core/classification'
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 { secretSchema } from './schema/secret.schema.js'
15
15
 
16
16
  export interface KyselySecretServiceConfig {
@@ -44,7 +44,7 @@ export class KyselySecretService implements SecretService {
44
44
 
45
45
  public async init(): Promise<void> {
46
46
  if (this.initialized) return
47
- await ensurePikkuSchema(this.db, secretSchema)
47
+ await requirePikkuSchema(this.db, secretSchema)
48
48
  this.initialized = true
49
49
  }
50
50
 
@@ -18,6 +18,30 @@ import { KyselyAgentRunService } from './kysely-agent-run-service.js'
18
18
  import { KyselySecretService } from './kysely-secret-service.js'
19
19
  import { KyselyCredentialService } from './kysely-credential-service.js'
20
20
  import { KyselySessionStore } from './kysely-session-store.js'
21
+ import {
22
+ agentSchema,
23
+ applyPikkuSchemas,
24
+ channelSchema,
25
+ credentialSchema,
26
+ deploymentSchema,
27
+ secretSchema,
28
+ sessionSchema,
29
+ workflowSchema,
30
+ } from './schema/index.js'
31
+
32
+ /**
33
+ * Every schema the services under test boot against. They no longer create
34
+ * their own tables, so the harness stands in for `pikku db migrate`.
35
+ */
36
+ const SERVICE_SCHEMAS = [
37
+ channelSchema,
38
+ workflowSchema,
39
+ deploymentSchema,
40
+ agentSchema,
41
+ secretSchema,
42
+ credentialSchema,
43
+ sessionSchema,
44
+ ]
21
45
 
22
46
  function createSqliteDb(): Kysely<KyselyPikkuDB> {
23
47
  return new Kysely<KyselyPikkuDB>({
@@ -345,6 +369,7 @@ describe('Kysely Services - SQLite', () => {
345
369
 
346
370
  before(async () => {
347
371
  db = createSqliteDb()
372
+ await applyPikkuSchemas(db, SERVICE_SCHEMAS)
348
373
  })
349
374
 
350
375
  after(async () => {
@@ -365,6 +390,7 @@ describe(
365
390
  before(async () => {
366
391
  db = createPostgresDb()!
367
392
  await dropAllTables(db)
393
+ await applyPikkuSchemas(db, SERVICE_SCHEMAS)
368
394
  })
369
395
 
370
396
  after(async () => {
@@ -3,7 +3,7 @@ import type { CoreUserSession } from '@pikku/core/types'
3
3
  import type { Kysely } from 'kysely'
4
4
  import type { KyselyPikkuDB } from './kysely-tables.js'
5
5
  import { parseJson } from './kysely-json.js'
6
- import { ensurePikkuSchema } from './schema/index.js'
6
+ import { requirePikkuSchema } from './schema/index.js'
7
7
  import { sessionSchema } from './schema/session.schema.js'
8
8
 
9
9
  export class KyselySessionStore implements SessionStore {
@@ -13,7 +13,7 @@ export class KyselySessionStore implements SessionStore {
13
13
 
14
14
  public async init(): Promise<void> {
15
15
  if (this.initialized) return
16
- await ensurePikkuSchema(this.db, sessionSchema)
16
+ await requirePikkuSchema(this.db, sessionSchema)
17
17
  this.initialized = true
18
18
  }
19
19
 
@@ -5,15 +5,7 @@ import { join } from 'node:path'
5
5
  import { Kysely, SqliteDialect } from 'kysely'
6
6
  import Database from 'better-sqlite3'
7
7
 
8
- import { applyPikkuSchemas, ensurePikkuSchema } from './schema/index.js'
9
- import { virtualUserSchema } from './schema/virtual-user.schema.js'
10
-
11
- /**
12
- * Opt-in schemas that still have an interface in `KyselyPikkuDB`, so the drift
13
- * check covers them too. A table being opt-in says when it is created, not
14
- * whether its type is allowed to disagree with its DDL.
15
- */
16
- const OPTIONAL_SCHEMAS = [virtualUserSchema]
8
+ import { applyPikkuSchemas } from './schema/index.js'
17
9
 
18
10
  /**
19
11
  * `KyselyPikkuDB` and the schema declaration describe the same tables, and
@@ -73,9 +65,6 @@ const declaredSchema = async (): Promise<Map<string, Set<string>>> => {
73
65
  .addColumn('id', 'text', (col) => col.primaryKey())
74
66
  .execute()
75
67
  await applyPikkuSchemas(db)
76
- for (const schema of OPTIONAL_SCHEMAS) {
77
- await ensurePikkuSchema(db, schema)
78
- }
79
68
 
80
69
  const tables = await db.introspection.getTables()
81
70
  return new Map(
@@ -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({