@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.
- package/CHANGELOG.md +82 -0
- package/dist/bin/pikku-kysely-pure.js +4 -1
- package/dist/src/classification-crypto.js +16 -2
- package/dist/src/index.d.ts +2 -2
- package/dist/src/index.js +2 -2
- package/dist/src/kysely-agent-run-service.js +2 -2
- package/dist/src/kysely-agent-storage-service.js +5 -9
- package/dist/src/kysely-analytics-service.js +2 -5
- package/dist/src/kysely-audit-service.js +2 -5
- package/dist/src/kysely-credential-service.js +7 -10
- package/dist/src/kysely-deployment-service.js +7 -10
- package/dist/src/kysely-eventhub-store.js +3 -4
- package/dist/src/kysely-feature-flag-store.d.ts +3 -3
- package/dist/src/kysely-feature-flag-store.js +47 -41
- package/dist/src/kysely-lease-clock.d.ts +1 -1
- package/dist/src/kysely-lease-clock.js +5 -1
- package/dist/src/kysely-lease-service.js +4 -5
- package/dist/src/kysely-scope-service.js +62 -42
- package/dist/src/kysely-secret-service.js +10 -17
- package/dist/src/kysely-session-store.js +3 -9
- package/dist/src/kysely-tables.d.ts +3 -3
- package/dist/src/kysely-trigger-source-store.js +3 -4
- package/dist/src/kysely-upsert.d.ts +37 -0
- package/dist/src/kysely-upsert.js +54 -0
- package/dist/src/kysely-virtual-user-run-store.d.ts +1 -1
- package/dist/src/kysely-virtual-user-run-store.js +8 -8
- package/dist/src/kysely-virtual-user-schedule-store.d.ts +4 -4
- package/dist/src/kysely-virtual-user-schedule-store.js +16 -19
- package/dist/src/kysely-workflow-mirror.js +2 -1
- package/dist/src/kysely-workflow-service.js +5 -1
- package/dist/src/schema/index.d.ts +11 -0
- package/dist/src/schema/index.js +29 -0
- package/dist/src/schema/virtual-user-schedule.schema.d.ts +3 -3
- package/dist/src/schema/virtual-user-schedule.schema.js +5 -5
- package/dist/src/schema/virtual-user.schema.d.ts +3 -3
- package/dist/src/schema/virtual-user.schema.js +9 -9
- package/knowledge/decisions/a-console-role-cannot-take-a-system-roles-name.md +25 -0
- package/knowledge/decisions/a-credential-with-no-user-is-one-row-per-name.md +22 -0
- package/knowledge/decisions/a-virtual-users-schedule-starts-disabled.md +23 -0
- package/knowledge/decisions/classification-driven-encryption-happens-in-a-plugin.md +1 -1
- package/knowledge/decisions/grants-reference-declared-scopes-and-users-by-foreign-key.md +29 -0
- package/knowledge/decisions/index.md +5 -2
- package/knowledge/decisions/the-secrets-audit-table-is-declared-even-when-auditing-is-off.md +22 -0
- package/knowledge/index.md +1 -0
- package/knowledge/questions/do-the-kysely-stores-check-who-calls-them.md +27 -0
- package/knowledge/questions/does-a-trigger-source-row-hold-only-provider-identifiers.md +26 -0
- package/knowledge/questions/index.md +17 -0
- package/knowledge/questions/should-pikku-user-sessions-expire-on-their-own.md +21 -0
- package/knowledge/questions/should-secret-and-credential-reads-be-audited-by-default.md +32 -0
- 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
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
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:
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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?.
|
|
119
|
-
query = query.where('
|
|
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
|
-
|
|
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
|
|
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(
|
|
22
|
+
get(virtualUserId: string): Promise<VirtualUserScheduleRecord | null>;
|
|
23
23
|
list(): Promise<VirtualUserScheduleRecord[]>;
|
|
24
24
|
due(now: Date): Promise<VirtualUserScheduleRecord[]>;
|
|
25
|
-
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(
|
|
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
|
|
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.
|
|
35
|
+
const existing = await this.get(schedule.virtualUserId);
|
|
35
36
|
const merged = {
|
|
36
|
-
|
|
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
|
-
|
|
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(
|
|
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('
|
|
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('
|
|
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(
|
|
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
|
|
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('
|
|
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(
|
|
125
|
+
async remove(virtualUserId) {
|
|
129
126
|
await this.init();
|
|
130
127
|
await this.db
|
|
131
128
|
.deleteFrom('virtualUserSchedule')
|
|
132
|
-
.where('
|
|
129
|
+
.where('virtualUserId', '=', virtualUserId)
|
|
133
130
|
.execute();
|
|
134
131
|
}
|
|
135
132
|
toRecord(row) {
|
|
136
133
|
return {
|
|
137
|
-
|
|
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
|
-
|
|
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
|
-
|
|
705
|
+
.$call((q) => insertOrIgnore(this.db, q, ['workflowName', 'graphHash']))
|
|
702
706
|
.execute();
|
|
703
707
|
}
|
|
704
708
|
async updateWorkflowVersionStatusImpl(name, graphHash, status) {
|