@pikku/kysely 0.13.34 → 0.13.37

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 (50) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/bin/pikku-kysely-pure.js +4 -1
  3. package/dist/src/classification-crypto.js +16 -2
  4. package/dist/src/index.d.ts +2 -2
  5. package/dist/src/index.js +2 -2
  6. package/dist/src/kysely-agent-run-service.js +2 -2
  7. package/dist/src/kysely-agent-storage-service.js +5 -9
  8. package/dist/src/kysely-analytics-service.js +2 -5
  9. package/dist/src/kysely-audit-service.js +2 -5
  10. package/dist/src/kysely-credential-service.js +7 -10
  11. package/dist/src/kysely-deployment-service.js +7 -10
  12. package/dist/src/kysely-eventhub-store.js +3 -4
  13. package/dist/src/kysely-feature-flag-store.d.ts +3 -3
  14. package/dist/src/kysely-feature-flag-store.js +47 -41
  15. package/dist/src/kysely-lease-clock.d.ts +1 -1
  16. package/dist/src/kysely-lease-clock.js +5 -1
  17. package/dist/src/kysely-lease-service.js +4 -5
  18. package/dist/src/kysely-scope-service.js +62 -42
  19. package/dist/src/kysely-secret-service.js +10 -17
  20. package/dist/src/kysely-session-store.js +3 -9
  21. package/dist/src/kysely-tables.d.ts +3 -3
  22. package/dist/src/kysely-trigger-source-store.js +3 -4
  23. package/dist/src/kysely-upsert.d.ts +37 -0
  24. package/dist/src/kysely-upsert.js +54 -0
  25. package/dist/src/kysely-virtual-user-run-store.d.ts +1 -1
  26. package/dist/src/kysely-virtual-user-run-store.js +8 -8
  27. package/dist/src/kysely-virtual-user-schedule-store.d.ts +4 -4
  28. package/dist/src/kysely-virtual-user-schedule-store.js +16 -19
  29. package/dist/src/kysely-workflow-mirror.js +2 -1
  30. package/dist/src/kysely-workflow-service.js +5 -1
  31. package/dist/src/schema/index.d.ts +11 -0
  32. package/dist/src/schema/index.js +29 -0
  33. package/dist/src/schema/virtual-user-schedule.schema.d.ts +3 -3
  34. package/dist/src/schema/virtual-user-schedule.schema.js +5 -5
  35. package/dist/src/schema/virtual-user.schema.d.ts +3 -3
  36. package/dist/src/schema/virtual-user.schema.js +9 -9
  37. package/knowledge/decisions/a-console-role-cannot-take-a-system-roles-name.md +25 -0
  38. package/knowledge/decisions/a-credential-with-no-user-is-one-row-per-name.md +22 -0
  39. package/knowledge/decisions/a-virtual-users-schedule-starts-disabled.md +23 -0
  40. package/knowledge/decisions/classification-driven-encryption-happens-in-a-plugin.md +1 -1
  41. package/knowledge/decisions/grants-reference-declared-scopes-and-users-by-foreign-key.md +29 -0
  42. package/knowledge/decisions/index.md +5 -2
  43. package/knowledge/decisions/the-secrets-audit-table-is-declared-even-when-auditing-is-off.md +22 -0
  44. package/knowledge/index.md +1 -0
  45. package/knowledge/questions/do-the-kysely-stores-check-who-calls-them.md +27 -0
  46. package/knowledge/questions/does-a-trigger-source-row-hold-only-provider-identifiers.md +26 -0
  47. package/knowledge/questions/index.md +17 -0
  48. package/knowledge/questions/should-pikku-user-sessions-expire-on-their-own.md +21 -0
  49. package/knowledge/questions/should-secret-and-credential-reads-be-audited-by-default.md +32 -0
  50. package/package.json +3 -2
@@ -1,5 +1,6 @@
1
1
  import { SystemRoleImmutableError, SystemRoleShadowedError, } from '@pikku/core/errors';
2
2
  import { requirePikkuSchema } from './schema/index.js';
3
+ import { INSERTED, insertOrIgnore, isMysql, upsert } from './kysely-upsert.js';
3
4
  import { scopeSchema } from './schema/scope.schema.js';
4
5
  /**
5
6
  * Resolves and administers user scopes against four self-created tables.
@@ -37,18 +38,11 @@ export class KyselyScopeService {
37
38
  async syncScopes(scopes) {
38
39
  await this.db.transaction().execute(async (trx) => {
39
40
  if (scopes.length > 0) {
40
- await trx
41
- .insertInto('pikkuScopes')
42
- .values(scopes.map((scope) => ({
41
+ await upsert(trx, trx.insertInto('pikkuScopes').values(scopes.map((scope) => ({
43
42
  name: scope.id,
44
43
  description: scope.description ?? null,
45
44
  declared: true,
46
- })))
47
- .onConflict((oc) => oc.column('name').doUpdateSet((eb) => ({
48
- description: eb.ref('excluded.description'),
49
- declared: true,
50
- })))
51
- .execute();
45
+ }))), ['name'], { description: INSERTED, declared: true }).execute();
52
46
  }
53
47
  const markStale = trx.updateTable('pikkuScopes').set({ declared: false });
54
48
  await (scopes.length > 0
@@ -93,20 +87,12 @@ export class KyselyScopeService {
93
87
  async syncSystemRoles(roles) {
94
88
  await this.db.transaction().execute(async (trx) => {
95
89
  for (const role of roles) {
96
- await trx
97
- .insertInto('pikkuRoles')
98
- .values({
90
+ await upsert(trx, trx.insertInto('pikkuRoles').values({
99
91
  name: role.name,
100
92
  description: role.description ?? null,
101
93
  system: true,
102
94
  declared: true,
103
- })
104
- .onConflict((oc) => oc.column('name').doUpdateSet((eb) => ({
105
- description: eb.ref('excluded.description'),
106
- system: true,
107
- declared: true,
108
- })))
109
- .execute();
95
+ }), ['name'], { description: INSERTED, system: true, declared: true }).execute();
110
96
  await trx
111
97
  .deleteFrom('pikkuRoleScopes')
112
98
  .where('role', '=', role.name)
@@ -215,11 +201,9 @@ export class KyselyScopeService {
215
201
  }));
216
202
  }
217
203
  async addUserToRole(userId, role, grantedBy) {
218
- await this.db
204
+ await insertOrIgnore(this.db, this.db
219
205
  .insertInto('pikkuUserRole')
220
- .values({ userId, role, grantedBy: grantedBy ?? null })
221
- .onConflict((oc) => oc.columns(['userId', 'role']).doNothing())
222
- .execute();
206
+ .values({ userId, role, grantedBy: grantedBy ?? null }), ['userId', 'role']).execute();
223
207
  }
224
208
  async removeUserFromRole(userId, role) {
225
209
  await this.db
@@ -266,11 +250,9 @@ export class KyselyScopeService {
266
250
  return byUser;
267
251
  }
268
252
  async addScopeToUser(userId, scope, grantedBy) {
269
- await this.db
253
+ await insertOrIgnore(this.db, this.db
270
254
  .insertInto('pikkuUserScope')
271
- .values({ userId, scope, grantedBy: grantedBy ?? null })
272
- .onConflict((oc) => oc.columns(['userId', 'scope']).doNothing())
273
- .execute();
255
+ .values({ userId, scope, grantedBy: grantedBy ?? null }), ['userId', 'scope']).execute();
274
256
  }
275
257
  async removeScopeFromUser(userId, scope) {
276
258
  await this.db
@@ -318,13 +300,31 @@ export class KyselyScopeService {
318
300
  return [];
319
301
  }
320
302
  const names = stale.map((s) => s.scope);
321
- const deleted = await this.db
322
- .deleteFrom('pikkuScopes')
323
- .where('name', 'in', names)
324
- .where('declared', '=', false)
325
- .returning('name')
326
- .execute();
327
- return deleted.map((r) => r.name);
303
+ // MySQL has no `returning`: read what the delete's predicate matches inside
304
+ // the same transaction, then delete it.
305
+ return this.db.transaction().execute(async (trx) => {
306
+ const matching = () => trx
307
+ .selectFrom('pikkuScopes')
308
+ .select('name')
309
+ .where('name', 'in', names)
310
+ .where('declared', '=', false);
311
+ if (isMysql(trx)) {
312
+ const rows = await matching().forUpdate().execute();
313
+ await trx
314
+ .deleteFrom('pikkuScopes')
315
+ .where('name', 'in', names)
316
+ .where('declared', '=', false)
317
+ .execute();
318
+ return rows.map((r) => r.name);
319
+ }
320
+ const deleted = await trx
321
+ .deleteFrom('pikkuScopes')
322
+ .where('name', 'in', names)
323
+ .where('declared', '=', false)
324
+ .returning('name')
325
+ .execute();
326
+ return deleted.map((r) => r.name);
327
+ });
328
328
  }
329
329
  /**
330
330
  * System roles the last sync found no declaration for, with how many people
@@ -358,13 +358,33 @@ export class KyselyScopeService {
358
358
  if (stale.length === 0) {
359
359
  return [];
360
360
  }
361
- const deleted = await this.db
362
- .deleteFrom('pikkuRoles')
363
- .where('name', 'in', stale.map((s) => s.role))
364
- .where('system', '=', true)
365
- .where('declared', '=', false)
366
- .returning('name')
367
- .execute();
368
- return deleted.map((r) => r.name);
361
+ const names = stale.map((s) => s.role);
362
+ return this.db.transaction().execute(async (trx) => {
363
+ if (isMysql(trx)) {
364
+ const rows = await trx
365
+ .selectFrom('pikkuRoles')
366
+ .select('name')
367
+ .where('name', 'in', names)
368
+ .where('system', '=', true)
369
+ .where('declared', '=', false)
370
+ .forUpdate()
371
+ .execute();
372
+ await trx
373
+ .deleteFrom('pikkuRoles')
374
+ .where('name', 'in', names)
375
+ .where('system', '=', true)
376
+ .where('declared', '=', false)
377
+ .execute();
378
+ return rows.map((r) => r.name);
379
+ }
380
+ const deleted = await trx
381
+ .deleteFrom('pikkuRoles')
382
+ .where('name', 'in', names)
383
+ .where('system', '=', true)
384
+ .where('declared', '=', false)
385
+ .returning('name')
386
+ .execute();
387
+ return deleted.map((r) => r.name);
388
+ });
369
389
  }
370
390
  }
@@ -1,6 +1,7 @@
1
1
  import { deriveKEK, envelopeDecrypt, envelopeEncrypt, envelopeRewrap, generateKEKSalt, } from '@pikku/core/crypto-utils';
2
2
  import { createSecretValue, isSecretValue } from '@pikku/core/classification';
3
3
  import { requirePikkuSchema } from './schema/index.js';
4
+ import { insertOrIgnore, timestampParam, upsert } from './kysely-upsert.js';
4
5
  import { secretSchema } from './schema/secret.schema.js';
5
6
  export class KyselySecretService {
6
7
  db;
@@ -37,7 +38,7 @@ export class KyselySecretService {
37
38
  id: crypto.randomUUID(),
38
39
  secretKey: secretKey,
39
40
  action,
40
- performedAt: new Date().toISOString(),
41
+ performedAt: timestampParam(this.db),
41
42
  })
42
43
  .execute();
43
44
  }
@@ -52,15 +53,11 @@ export class KyselySecretService {
52
53
  .executeTakeFirst();
53
54
  let salt = existing?.salt;
54
55
  if (!salt) {
55
- await this.db
56
- .insertInto('secretKekSalts')
57
- .values({
56
+ await insertOrIgnore(this.db, this.db.insertInto('secretKekSalts').values({
58
57
  keyVersion: version,
59
58
  salt: generateKEKSalt(),
60
- createdAt: new Date().toISOString(),
61
- })
62
- .onConflict((oc) => oc.column('keyVersion').doNothing())
63
- .execute();
59
+ createdAt: timestampParam(this.db),
60
+ }), ['keyVersion']).execute();
64
61
  const row = await this.db
65
62
  .selectFrom('secretKekSalts')
66
63
  .select('salt')
@@ -108,24 +105,20 @@ export class KyselySecretService {
108
105
  // Encrypting the wrapper would store its redaction, not the secret —
109
106
  // writing a secret back to the vault is exactly what this method is for.
110
107
  const { ciphertext, wrappedDEK } = await envelopeEncrypt(await this.getKEK(this.keyVersion), isSecretValue(value) ? value.reveal() : value);
111
- const now = new Date().toISOString();
112
- await this.db
113
- .insertInto('secrets')
114
- .values({
108
+ const now = timestampParam(this.db);
109
+ await upsert(this.db, this.db.insertInto('secrets').values({
115
110
  key,
116
111
  ciphertext,
117
112
  wrappedDek: wrappedDEK,
118
113
  keyVersion: this.keyVersion,
119
114
  createdAt: now,
120
115
  updatedAt: now,
121
- })
122
- .onConflict((oc) => oc.column('key').doUpdateSet({
116
+ }), ['key'], {
123
117
  ciphertext,
124
118
  wrappedDek: wrappedDEK,
125
119
  keyVersion: this.keyVersion,
126
120
  updatedAt: now,
127
- }))
128
- .execute();
121
+ }).execute();
129
122
  await this.logAudit(key, 'write');
130
123
  }
131
124
  async deleteSecret(key) {
@@ -175,7 +168,7 @@ export class KyselySecretService {
175
168
  .set({
176
169
  wrappedDek: newWrappedDEK,
177
170
  keyVersion: this.keyVersion,
178
- updatedAt: new Date().toISOString(),
171
+ updatedAt: timestampParam(this.db),
179
172
  })
180
173
  .where('key', '=', row.key)
181
174
  .execute();
@@ -1,5 +1,6 @@
1
1
  import { parseJson } from './kysely-json.js';
2
2
  import { requirePikkuSchema } from './schema/index.js';
3
+ import { upsert } from './kysely-upsert.js';
3
4
  import { sessionSchema } from './schema/session.schema.js';
4
5
  export class KyselySessionStore {
5
6
  db;
@@ -25,18 +26,11 @@ export class KyselySessionStore {
25
26
  return (parseJson(row.session) ?? undefined);
26
27
  }
27
28
  async set(pikkuUserId, session) {
28
- await this.db
29
- .insertInto('pikkuUserSessions')
30
- .values({
29
+ await upsert(this.db, this.db.insertInto('pikkuUserSessions').values({
31
30
  pikkuUserId,
32
31
  session: JSON.stringify(session),
33
32
  updatedAt: new Date(),
34
- })
35
- .onConflict((oc) => oc.column('pikkuUserId').doUpdateSet({
36
- session: JSON.stringify(session),
37
- updatedAt: new Date(),
38
- }))
39
- .execute();
33
+ }), ['pikkuUserId'], { session: JSON.stringify(session), updatedAt: new Date() }).execute();
40
34
  }
41
35
  async clear(pikkuUserId) {
42
36
  await this.db
@@ -329,7 +329,7 @@ export interface WebhookReceiptTable {
329
329
  */
330
330
  export interface VirtualUserRunTable {
331
331
  runId: string;
332
- persona: string;
332
+ virtualUserId: string;
333
333
  disposition: string;
334
334
  /**
335
335
  * Read back as a string by some drivers — a BIGINT does not always fit a JS
@@ -424,11 +424,11 @@ export interface VirtualUserRunStepTable {
424
424
  tokensOut: Generated<number>;
425
425
  }
426
426
  /**
427
- * One persona's cadence. Nullable timestamps and 0/1 flags for the same driver
427
+ * One virtual user's cadence. Nullable timestamps and 0/1 flags for the same driver
428
428
  * reasons as {@link VirtualUserRunTable}.
429
429
  */
430
430
  export interface VirtualUserScheduleTable {
431
- persona: string;
431
+ virtualUserId: string;
432
432
  /** 0 or 1: not every driver in this package can bind a boolean. */
433
433
  enabled: Generated<number>;
434
434
  disposition: string;
@@ -1,4 +1,5 @@
1
1
  import { requirePikkuSchema } from './schema/index.js';
2
+ import { upsert } from './kysely-upsert.js';
2
3
  import { triggerSourceSchema } from './schema/trigger-source.schema.js';
3
4
  const parseState = (value) => {
4
5
  if (!value)
@@ -43,11 +44,9 @@ export class KyselyTriggerSourceStore {
43
44
  ...(baseUrl !== undefined ? { baseUrl } : {}),
44
45
  ...(labelPrefix !== undefined ? { labelPrefix } : {}),
45
46
  };
46
- await trx
47
+ await upsert(trx, trx
47
48
  .insertInto('pikkuTriggerSource')
48
- .values({ name, kind, declared: true, ...address })
49
- .onConflict((oc) => oc.column('name').doUpdateSet({ kind, declared: true, ...address }))
50
- .execute();
49
+ .values({ name, kind, declared: true, ...address }), ['name'], { kind, declared: true, ...address }).execute();
51
50
  }
52
51
  const markStale = trx
53
52
  .updateTable('pikkuTriggerSource')
@@ -0,0 +1,37 @@
1
+ import { type Kysely } from 'kysely';
2
+ /**
3
+ * Whether `db` talks to MySQL.
4
+ *
5
+ * Read from the dialect's adapter rather than configured, so a store handed any
6
+ * Kysely instance (or an open transaction) answers for the engine it will
7
+ * actually run against.
8
+ */
9
+ export declare const isMysql: (db: Kysely<any>) => boolean;
10
+ /** In an upsert's update set: take the value the insert proposed for this column. */
11
+ export declare const INSERTED: unique symbol;
12
+ /**
13
+ * Insert, leaving an existing row alone when `conflictColumns` already match one.
14
+ *
15
+ * Postgres and sqlite spell this `on conflict do nothing`. MySQL has no such
16
+ * clause; `insert ignore` is the closest, and unlike `on conflict (cols)` it
17
+ * also swallows other row errors, so it is only used where the conflict target
18
+ * is the table's key.
19
+ */
20
+ export declare const insertOrIgnore: (db: Kysely<any>, query: any, conflictColumns?: string[]) => any;
21
+ /**
22
+ * Insert, or update the row `conflictColumns` match.
23
+ *
24
+ * `set` maps column to the new value, or to `INSERTED` for "whatever this
25
+ * insert carried" — `excluded.col` on postgres and sqlite, `values(col)` on
26
+ * MySQL, which has no `excluded`.
27
+ */
28
+ export declare const upsert: (db: Kysely<any>, query: any, conflictColumns: string[], set: Record<string, unknown>) => any;
29
+ /**
30
+ * A moment as a `timestamp` column's parameter.
31
+ *
32
+ * An ISO 8601 string with its trailing `Z` is accepted by postgres and stored
33
+ * as written by sqlite, but MySQL refuses it for a `TIMESTAMP` column
34
+ * (`Incorrect datetime value`). The driver formats a `Date` for MySQL itself,
35
+ * so that is what it is given.
36
+ */
37
+ export declare const timestampParam: (db: Kysely<any>, at?: Date) => Date;
@@ -0,0 +1,54 @@
1
+ import { MysqlAdapter } from 'kysely';
2
+ /**
3
+ * Whether `db` talks to MySQL.
4
+ *
5
+ * Read from the dialect's adapter rather than configured, so a store handed any
6
+ * Kysely instance (or an open transaction) answers for the engine it will
7
+ * actually run against.
8
+ */
9
+ export const isMysql = (db) => db.getExecutor().adapter instanceof MysqlAdapter;
10
+ /** In an upsert's update set: take the value the insert proposed for this column. */
11
+ export const INSERTED = Symbol('inserted');
12
+ /**
13
+ * Insert, leaving an existing row alone when `conflictColumns` already match one.
14
+ *
15
+ * Postgres and sqlite spell this `on conflict do nothing`. MySQL has no such
16
+ * clause; `insert ignore` is the closest, and unlike `on conflict (cols)` it
17
+ * also swallows other row errors, so it is only used where the conflict target
18
+ * is the table's key.
19
+ */
20
+ export const insertOrIgnore = (db, query, conflictColumns) => isMysql(db)
21
+ ? query.ignore()
22
+ : query.onConflict((oc) => conflictColumns
23
+ ? oc.columns(conflictColumns).doNothing()
24
+ : oc.doNothing());
25
+ /**
26
+ * Insert, or update the row `conflictColumns` match.
27
+ *
28
+ * `set` maps column to the new value, or to `INSERTED` for "whatever this
29
+ * insert carried" — `excluded.col` on postgres and sqlite, `values(col)` on
30
+ * MySQL, which has no `excluded`.
31
+ */
32
+ export const upsert = (db, query, conflictColumns, set) => {
33
+ if (isMysql(db)) {
34
+ return query.onDuplicateKeyUpdate((eb) => Object.fromEntries(Object.entries(set).map(([column, value]) => [
35
+ column,
36
+ value === INSERTED ? eb.fn('values', [eb.ref(column)]) : value,
37
+ ])));
38
+ }
39
+ return query.onConflict((oc) => oc
40
+ .columns(conflictColumns)
41
+ .doUpdateSet((eb) => Object.fromEntries(Object.entries(set).map(([column, value]) => [
42
+ column,
43
+ value === INSERTED ? eb.ref(`excluded.${column}`) : value,
44
+ ]))));
45
+ };
46
+ /**
47
+ * A moment as a `timestamp` column's parameter.
48
+ *
49
+ * An ISO 8601 string with its trailing `Z` is accepted by postgres and stored
50
+ * as written by sqlite, but MySQL refuses it for a `TIMESTAMP` column
51
+ * (`Incorrect datetime value`). The driver formats a `Date` for MySQL itself,
52
+ * so that is what it is given.
53
+ */
54
+ export const timestampParam = (db, at = new Date()) => isMysql(db) ? at : at.toISOString();
@@ -17,7 +17,7 @@ export declare class KyselyVirtualUserRunStore implements VirtualUserRunStore {
17
17
  fail(runId: string, error: string): Promise<void>;
18
18
  get(runId: string): Promise<VirtualUserRunRecord | null>;
19
19
  list(options?: {
20
- persona?: string;
20
+ virtualUserId?: string;
21
21
  limit?: number;
22
22
  offset?: number;
23
23
  }): Promise<VirtualUserRunRecord[]>;
@@ -1,7 +1,7 @@
1
- import { randomUUID } from 'node:crypto';
2
1
  import { parseJson } from './kysely-json.js';
3
2
  import { requirePikkuSchema } from './schema/index.js';
4
3
  import { virtualUserSchema } from './schema/virtual-user.schema.js';
4
+ import { timestampParam } from './kysely-upsert.js';
5
5
  /**
6
6
  * Records virtual-user runs in a `virtualUserRun` table.
7
7
  *
@@ -49,12 +49,12 @@ export class KyselyVirtualUserRunStore {
49
49
  }
50
50
  async start(run) {
51
51
  await this.init();
52
- const runId = randomUUID();
52
+ const runId = globalThis.crypto.randomUUID();
53
53
  await this.db
54
54
  .insertInto('virtualUserRun')
55
55
  .values({
56
56
  runId,
57
- persona: run.persona,
57
+ virtualUserId: run.virtualUserId,
58
58
  disposition: run.disposition,
59
59
  // Text on every engine (see the schema), so the seed round-trips
60
60
  // through drivers that hand a BIGINT back as a string.
@@ -81,7 +81,7 @@ export class KyselyVirtualUserRunStore {
81
81
  memory: JSON.stringify(outcome.memory),
82
82
  intents: JSON.stringify(outcome.intents),
83
83
  stoppedBy: outcome.stoppedBy,
84
- finishedAt: new Date().toISOString(),
84
+ finishedAt: timestampParam(this.db),
85
85
  })
86
86
  .where('runId', '=', runId)
87
87
  .executeTakeFirst();
@@ -98,7 +98,7 @@ export class KyselyVirtualUserRunStore {
98
98
  .set({
99
99
  status: 'failed',
100
100
  error,
101
- finishedAt: new Date().toISOString(),
101
+ finishedAt: timestampParam(this.db),
102
102
  })
103
103
  .where('runId', '=', runId)
104
104
  .execute();
@@ -115,8 +115,8 @@ export class KyselyVirtualUserRunStore {
115
115
  async list(options) {
116
116
  await this.init();
117
117
  let query = this.db.selectFrom('virtualUserRun').selectAll();
118
- if (options?.persona) {
119
- query = query.where('persona', '=', options.persona);
118
+ if (options?.virtualUserId) {
119
+ query = query.where('virtualUserId', '=', options.virtualUserId);
120
120
  }
121
121
  const rows = await query
122
122
  .orderBy('createdAt', 'desc')
@@ -179,7 +179,7 @@ export class KyselyVirtualUserRunStore {
179
179
  toRecord(row) {
180
180
  return {
181
181
  runId: row.runId,
182
- persona: row.persona,
182
+ virtualUserId: row.virtualUserId,
183
183
  disposition: row.disposition,
184
184
  seed: Number(row.seed),
185
185
  status: row.status,
@@ -2,7 +2,7 @@ import type { Kysely } from 'kysely';
2
2
  import type { VirtualUserScheduleInput, VirtualUserScheduleRecord, VirtualUserScheduleStore } from '@pikku/core/virtual-user';
3
3
  import type { KyselyPikkuDB } from './kysely-tables.js';
4
4
  /**
5
- * Keeps each persona's cadence in a `virtualUserSchedule` table.
5
+ * Keeps each virtual user's cadence in a `virtualUserSchedule` table.
6
6
  *
7
7
  * Wiring this is what turns virtual users from something a person triggers into
8
8
  * something that happens: without it, {@link KyselyVirtualUserRunStore} still
@@ -19,15 +19,15 @@ export declare class KyselyVirtualUserScheduleStore implements VirtualUserSchedu
19
19
  constructor(db: Kysely<KyselyPikkuDB>);
20
20
  init(): Promise<void>;
21
21
  set(schedule: VirtualUserScheduleInput): Promise<VirtualUserScheduleRecord>;
22
- get(persona: string): Promise<VirtualUserScheduleRecord | null>;
22
+ get(virtualUserId: string): Promise<VirtualUserScheduleRecord | null>;
23
23
  list(): Promise<VirtualUserScheduleRecord[]>;
24
24
  due(now: Date): Promise<VirtualUserScheduleRecord[]>;
25
- claim(persona: string, claim: {
25
+ claim(virtualUserId: string, claim: {
26
26
  from: Date;
27
27
  nextRunAt: Date;
28
28
  runId: string | null;
29
29
  at: Date;
30
30
  }): Promise<boolean>;
31
- remove(persona: string): Promise<void>;
31
+ remove(virtualUserId: string): Promise<void>;
32
32
  private toRecord;
33
33
  }
@@ -1,9 +1,10 @@
1
1
  import { DEFAULT_MAX_INTERVAL_MS, DEFAULT_MIN_INTERVAL_MS, } from '@pikku/core/virtual-user';
2
2
  import { parseJson } from './kysely-json.js';
3
3
  import { requirePikkuSchema } from './schema/index.js';
4
+ import { upsert } from './kysely-upsert.js';
4
5
  import { virtualUserScheduleSchema } from './schema/virtual-user-schedule.schema.js';
5
6
  /**
6
- * Keeps each persona's cadence in a `virtualUserSchedule` table.
7
+ * Keeps each virtual user's cadence in a `virtualUserSchedule` table.
7
8
  *
8
9
  * Wiring this is what turns virtual users from something a person triggers into
9
10
  * something that happens: without it, {@link KyselyVirtualUserRunStore} still
@@ -31,9 +32,9 @@ export class KyselyVirtualUserScheduleStore {
31
32
  // Read-then-write rather than a partial upsert: a schedule is configuration
32
33
  // written by a person, and the alternative is two dialects of "leave the
33
34
  // columns I did not mention alone".
34
- const existing = await this.get(schedule.persona);
35
+ const existing = await this.get(schedule.virtualUserId);
35
36
  const merged = {
36
- persona: schedule.persona,
37
+ virtualUserId: schedule.virtualUserId,
37
38
  enabled: schedule.enabled ?? existing?.enabled ?? false,
38
39
  disposition: schedule.disposition ?? existing?.disposition ?? 'realistic',
39
40
  goals: [...(schedule.goals ?? existing?.goals ?? [])],
@@ -53,7 +54,7 @@ export class KyselyVirtualUserScheduleStore {
53
54
  lastRunAt: existing?.lastRunAt ?? null,
54
55
  };
55
56
  const values = {
56
- persona: merged.persona,
57
+ virtualUserId: merged.virtualUserId,
57
58
  enabled: merged.enabled ? 1 : 0,
58
59
  disposition: merged.disposition,
59
60
  goals: JSON.stringify(merged.goals),
@@ -62,10 +63,7 @@ export class KyselyVirtualUserScheduleStore {
62
63
  maxIntervalMs: String(merged.maxIntervalMs),
63
64
  nextRunAt: merged.nextRunAt.toISOString(),
64
65
  };
65
- await this.db
66
- .insertInto('virtualUserSchedule')
67
- .values(values)
68
- .onConflict((oc) => oc.column('persona').doUpdateSet({
66
+ await upsert(this.db, this.db.insertInto('virtualUserSchedule').values(values), ['virtualUserId'], {
69
67
  enabled: values.enabled,
70
68
  disposition: values.disposition,
71
69
  goals: values.goals,
@@ -73,16 +71,15 @@ export class KyselyVirtualUserScheduleStore {
73
71
  minIntervalMs: values.minIntervalMs,
74
72
  maxIntervalMs: values.maxIntervalMs,
75
73
  nextRunAt: values.nextRunAt,
76
- }))
77
- .execute();
74
+ }).execute();
78
75
  return merged;
79
76
  }
80
- async get(persona) {
77
+ async get(virtualUserId) {
81
78
  await this.init();
82
79
  const row = await this.db
83
80
  .selectFrom('virtualUserSchedule')
84
81
  .selectAll()
85
- .where('persona', '=', persona)
82
+ .where('virtualUserId', '=', virtualUserId)
86
83
  .executeTakeFirst();
87
84
  return row ? this.toRecord(row) : null;
88
85
  }
@@ -91,7 +88,7 @@ export class KyselyVirtualUserScheduleStore {
91
88
  const rows = await this.db
92
89
  .selectFrom('virtualUserSchedule')
93
90
  .selectAll()
94
- .orderBy('persona', 'asc')
91
+ .orderBy('virtualUserId', 'asc')
95
92
  .execute();
96
93
  return rows.map((row) => this.toRecord(row));
97
94
  }
@@ -106,35 +103,35 @@ export class KyselyVirtualUserScheduleStore {
106
103
  .execute();
107
104
  return rows.map((row) => this.toRecord(row));
108
105
  }
109
- async claim(persona, claim) {
106
+ async claim(virtualUserId, claim) {
110
107
  await this.init();
111
108
  const result = await this.db
112
109
  .updateTable('virtualUserSchedule')
113
110
  .set({
114
111
  nextRunAt: claim.nextRunAt.toISOString(),
115
112
  // 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.
113
+ // not blank out which run this virtual user last managed to start.
117
114
  ...(claim.runId
118
115
  ? { lastRunId: claim.runId, lastRunAt: claim.at.toISOString() }
119
116
  : {}),
120
117
  })
121
- .where('persona', '=', persona)
118
+ .where('virtualUserId', '=', virtualUserId)
122
119
  // The compare-and-set: whoever writes first moves `nextRunAt`, and every
123
120
  // other tick holding the value they read matches nothing.
124
121
  .where('nextRunAt', '=', claim.from.toISOString())
125
122
  .executeTakeFirst();
126
123
  return Number(result.numUpdatedRows ?? 0) > 0;
127
124
  }
128
- async remove(persona) {
125
+ async remove(virtualUserId) {
129
126
  await this.init();
130
127
  await this.db
131
128
  .deleteFrom('virtualUserSchedule')
132
- .where('persona', '=', persona)
129
+ .where('virtualUserId', '=', virtualUserId)
133
130
  .execute();
134
131
  }
135
132
  toRecord(row) {
136
133
  return {
137
- persona: row.persona,
134
+ virtualUserId: row.virtualUserId,
138
135
  enabled: Boolean(row.enabled),
139
136
  disposition: row.disposition,
140
137
  goals: parseJson(row.goals) ?? [],
@@ -1,4 +1,5 @@
1
1
  import { requirePikkuSchema } from './schema/index.js';
2
+ import { insertOrIgnore } from './kysely-upsert.js';
2
3
  import { workflowSchema } from './schema/workflow.schema.js';
3
4
  /**
4
5
  * Kysely-backed `WorkflowRunMirror`.
@@ -181,7 +182,7 @@ export class KyselyWorkflowMirror {
181
182
  source,
182
183
  status,
183
184
  })
184
- .onConflict((oc) => oc.columns(['workflowName', 'graphHash']).doNothing())
185
+ .$call((q) => insertOrIgnore(this.db, q, ['workflowName', 'graphHash']))
185
186
  .execute();
186
187
  }
187
188
  async updateWorkflowVersionStatus(name, graphHash, status) {
@@ -4,6 +4,7 @@ import { appNowMs, leaseUntil } from './kysely-lease-clock.js';
4
4
  import { KyselyWorkflowRunService } from './kysely-workflow-run-service.js';
5
5
  import { parseJson } from './kysely-json.js';
6
6
  import { requirePikkuSchema } from './schema/index.js';
7
+ import { insertOrIgnore, isMysql } from './kysely-upsert.js';
7
8
  import { workflowSchema } from './schema/workflow.schema.js';
8
9
  /**
9
10
  * What a step read returns, one list for the single read and the whole-run
@@ -590,6 +591,9 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
590
591
  * @param json - The value as JSON text
591
592
  */
592
593
  jsonSetState(path, json) {
594
+ if (isMysql(this.db)) {
595
+ return sql `JSON_SET(COALESCE(state, '{}'), ${path}, CAST(${json} AS JSON))`;
596
+ }
593
597
  return sql `json_set(coalesce(state, '{}'), ${path}, json(${json}))`;
594
598
  }
595
599
  /**
@@ -698,7 +702,7 @@ export class KyselyWorkflowService extends PikkuWorkflowService {
698
702
  source,
699
703
  status: status ?? 'active',
700
704
  })
701
- .onConflict((oc) => oc.columns(['workflowName', 'graphHash']).doNothing())
705
+ .$call((q) => insertOrIgnore(this.db, q, ['workflowName', 'graphHash']))
702
706
  .execute();
703
707
  }
704
708
  async updateWorkflowVersionStatusImpl(name, graphHash, status) {