@pikku/kysely 0.13.35 → 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 +49 -0
- 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 +3 -9
- 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 +7 -6
- 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 +2 -2
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,52 @@
|
|
|
1
|
+
## 0.13.37
|
|
2
|
+
|
|
3
|
+
### Patch Changes
|
|
4
|
+
|
|
5
|
+
- fdcc821: Fabric is gone from the open toolchain. The previous names are removed with no aliases.
|
|
6
|
+
|
|
7
|
+
The CLI group is `pikku cloud …` and the export is `@pikku/cli/cloud`. The project link is the `cloud` key of `pikku.config.json` (`cloud.projectId`), the CLI keeps its local state in `~/.pikkustudio` (run `pikku cloud login` again), and the project config file is `pikkustudio.config.json`.
|
|
8
|
+
|
|
9
|
+
Every environment variable drops the `FABRIC_` prefix for `PIKKUSTUDIO_` with the same suffix: `PIKKUSTUDIO_API_URL`, `PIKKUSTUDIO_PROJECT_ID`, `PIKKUSTUDIO_TOKEN`, `PIKKUSTUDIO_OPERATOR_TOKEN`, `PIKKUSTUDIO_AUTH_PUBLIC_KEY`, `PIKKUSTUDIO_STAGE_ID` and the rest. Rename them wherever you set them.
|
|
10
|
+
|
|
11
|
+
Wire ids: the operator sign-in route is `/sign-in/cloud`, the plugin and mode literal is `'cloud'`, the Better Auth plugin is `pikkuCloud`, its session field is `pikkustudioadmin`, and the signature header is `x-cloud-sig`. The cloud API calls are `getCloudLogs`, `getCloudLogsByStageKind`, `cloudCliProjects`, `cloudCliDeployments`, `providedByCloud` and `recordCloudAnalyticsEvents`. Addon packages are `@pikkustudio/*`, scaffolded files live under `scaffold/cloud` and `__cloud_*__`, and the API hosts are the `pikkustudio.app` and `pikkustudio.dev` domains.
|
|
12
|
+
|
|
13
|
+
`pikku skills install --fabric` is `--cloud`, the install group is `cloud`, and the skills are `pikku-cloud`, `pikku-cloud-debug`, `pikku-admin-to-cloud` and `pikku-blueprint-to-cloud`.
|
|
14
|
+
|
|
15
|
+
The stale analytics forwarder copies are removed from the online-shop example and the starter template.
|
|
16
|
+
|
|
17
|
+
The sealed-box salt stays `pikkufabric/sealed-box/v1`: it is a cryptographic constant that already-sealed secrets depend on.
|
|
18
|
+
|
|
19
|
+
- 33b102d: Virtual users now have one name everywhere. The id column of `virtual_user_run` and `virtual_user_schedule` is `virtual_user_id` (a migration renames it in place), the CLI group is `pikku virtual-user …`, and the declarations and imports live under `@pikku/core/virtual-user`. The previous names are removed with no aliases.
|
|
20
|
+
|
|
21
|
+
The default virtual user email domain is now `virtualuser.invalid`: apps that rely on the default domain get new accounts at the next provisioning (`pikku virtual-user sync`, deploy, or `pikku dev` start), and the old rows stay unused.
|
|
22
|
+
|
|
23
|
+
The sign-in surface carries the same name. The route is `/sign-in/virtual-user`, the environment variables are `PIKKU_ALLOW_VIRTUAL_USER_SIGN_IN` and the `PIKKU_DEV_VIRTUAL_USER_SIGN_IN` / `VITE_*` equivalents, the Better Auth plugin is `pikkuVirtualUser`, the scenario step option and wire member are `virtualUser` and `virtualUsers`, and the Better Auth user flag column is `virtual_user`. Rename the environment variables wherever you set them, and add a migration that renames the flag column on the `user` table. Nothing is aliased.
|
|
24
|
+
|
|
25
|
+
The conversation runner is now `@pikku/core/virtual-user/conversation`, replacing the old `-flow` subpath. Update the import; the old subpath is gone.
|
|
26
|
+
|
|
27
|
+
- Updated dependencies [fdcc821]
|
|
28
|
+
- Updated dependencies [33b102d]
|
|
29
|
+
- Updated dependencies [6a25c70]
|
|
30
|
+
- Updated dependencies [a084c3b]
|
|
31
|
+
- @pikku/core@0.12.142
|
|
32
|
+
|
|
33
|
+
## 0.13.36
|
|
34
|
+
|
|
35
|
+
### Patch Changes
|
|
36
|
+
|
|
37
|
+
- e0846b5: Saving an agent's tool messages skips a tool call whose result is undefined instead of writing it. When the browser driver for a scenario cannot be loaded, the error now includes the underlying cause and tells you to `bun add -D` it.
|
|
38
|
+
- 884a1b5: MySQL: the `@pikku/kysely` stores no longer emit postgres/sqlite-only SQL. Upserts use `on duplicate key update`, insert-or-ignore uses `insert ignore`, `returning` deletes read the rows first, the lease clock casts to `signed`, workflow JSON state uses `CAST(... AS JSON)`, the agent thread owner `LIKE` escapes with `!`, and timestamp columns are written as `Date`s instead of `Z`-suffixed ISO strings. `pikku db generate` now emits the `scope` schema when Better Auth's user model is a mapped table (for example a Rails `users` table), typing the key column from the real `users.id`.
|
|
39
|
+
- 51ec3ac: MySQL fixes found moving a Rails database onto pikku.
|
|
40
|
+
|
|
41
|
+
- The generated local services (`pikku db baseline`, `pikku dev`, `pikku all`, a local CLI) open MySQL: a `mysql://` `DATABASE_URL` or `mysqlUrl` in the config opens through a Kysely `MysqlDialect` on a `mysql2` pool (declare `mysql2` in the project), with `CamelCasePlugin` and the coercion map like the other dialects. A `mysql://` URL was read as a sqlite file path. `db/mysql` now counts as a project database, and `@pikku/kysely` re-exports `MysqlDialect`.
|
|
42
|
+
- MySQL migrations run with `FOREIGN_KEY_CHECKS` off, so a mysqldump whose tables reference ones created later applies as it is.
|
|
43
|
+
- `TINYINT(1)` is typed `boolean` and coerced at runtime (mysql2 returns 0/1); an explicit `tsType` in `db/annotations.ts` keeps it a number. `DECIMAL` is read as a number (`decimalNumbers`), as the generated types say. `BIGINT` stays `number`, exact to 2^53, which is mysql2's default.
|
|
44
|
+
- `pikku all` no longer throws "db/mysql exists but no mysqlUrl is configured": the server comes from createConfig's `mysqlUrl`, `db.mysqlUrl` or a `mysql://` `DATABASE_URL`, and with none the stub `DB` is written.
|
|
45
|
+
- Better Auth schema derivation follows the resolved database rather than the factory's `database.type`, so a factory declaring `type: 'sqlite'` no longer sends SQLite introspection (`pragma index_list`) to a MySQL server. Set `type: 'mysql'` in the factory too, so the runtime matches.
|
|
46
|
+
|
|
47
|
+
- Updated dependencies [f396538]
|
|
48
|
+
- @pikku/core@0.12.140
|
|
49
|
+
|
|
1
50
|
## 0.13.35
|
|
2
51
|
|
|
3
52
|
### Patch Changes
|
package/dist/src/index.d.ts
CHANGED
|
@@ -22,12 +22,12 @@ export { KyselyAuditService } from './kysely-audit-service.js';
|
|
|
22
22
|
export { KyselyAnalyticsService } from './kysely-analytics-service.js';
|
|
23
23
|
export { KyselyVirtualUserRunStore } from './kysely-virtual-user-run-store.js';
|
|
24
24
|
export { KyselyVirtualUserScheduleStore } from './kysely-virtual-user-schedule-store.js';
|
|
25
|
-
export { CamelCasePlugin, Kysely, PostgresDialect } from 'kysely';
|
|
25
|
+
export { CamelCasePlugin, Kysely, MysqlDialect, PostgresDialect } from 'kysely';
|
|
26
26
|
export { SerializePlugin, BaseSerializePlugin, type Serializer, type Deserializer, } from './serialize-plugin.js';
|
|
27
27
|
export { createCoercionPlugin, type ColumnKind, type CoercionMap, type CreateCoercionPluginOptions, } from './coercion-plugin.js';
|
|
28
28
|
export { createClassificationPlugin, type CreateClassificationPluginOptions, } from './classification-plugin.js';
|
|
29
29
|
export { ClassificationCrypto, DEFAULT_KEY_ID, isColumnEnvelope, parseColumnEnvelope, type ClassificationCryptoOptions, type ColumnEnvelope, type KEKResolver, type ResolvedKEK, } from './classification-crypto.js';
|
|
30
|
-
export { agentSchema, analyticsSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, flagSchema, incomingWebhookSchema, leaseSchema, scopeSchema, secretSchema, sessionSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, type PikkuSchema, type RequiredTypes, type UnmetRequirement, type SchemaStatementFactory, } from './schema/index.js';
|
|
30
|
+
export { agentSchema, analyticsSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, flagSchema, incomingWebhookSchema, leaseSchema, scopeSchema, secretSchema, sessionSchema, triggerSourceSchema, virtualUserScheduleSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, withUserTable, type PikkuSchema, type RequiredTypes, type UnmetRequirement, type SchemaStatementFactory, } from './schema/index.js';
|
|
31
31
|
export type { KyselyPikkuDB } from './kysely-tables.js';
|
|
32
32
|
export type { WorkflowRunService } from '@pikku/core/workflow';
|
|
33
33
|
export type { AgentRunService, AgentRunRow } from '@pikku/core/agent';
|
package/dist/src/index.js
CHANGED
|
@@ -24,9 +24,9 @@ export { KyselyVirtualUserScheduleStore } from './kysely-virtual-user-schedule-s
|
|
|
24
24
|
// Re-exported so a generated file that constructs the services above opens and
|
|
25
25
|
// types its database with the same copy of kysely those services were built
|
|
26
26
|
// against, rather than a second copy the project may resolve on its own.
|
|
27
|
-
export { CamelCasePlugin, Kysely, PostgresDialect } from 'kysely';
|
|
27
|
+
export { CamelCasePlugin, Kysely, MysqlDialect, PostgresDialect } from 'kysely';
|
|
28
28
|
export { SerializePlugin, BaseSerializePlugin, } from './serialize-plugin.js';
|
|
29
29
|
export { createCoercionPlugin, } from './coercion-plugin.js';
|
|
30
30
|
export { createClassificationPlugin, } from './classification-plugin.js';
|
|
31
31
|
export { ClassificationCrypto, DEFAULT_KEY_ID, isColumnEnvelope, parseColumnEnvelope, } from './classification-crypto.js';
|
|
32
|
-
export { agentSchema, analyticsSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, flagSchema, incomingWebhookSchema, leaseSchema, scopeSchema, secretSchema, sessionSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, } from './schema/index.js';
|
|
32
|
+
export { agentSchema, analyticsSchema, auditSchema, channelSchema, credentialSchema, deploymentSchema, flagSchema, incomingWebhookSchema, leaseSchema, scopeSchema, secretSchema, sessionSchema, triggerSourceSchema, virtualUserScheduleSchema, virtualUserSchema, webhookSchema, workflowSchema, pikkuSchemas, requiredPikkuSchemas, applyPikkuSchemas, compilePikkuSchemas, requirePikkuSchema, resolveRequirements, withUserTable, } from './schema/index.js';
|
|
@@ -2,7 +2,7 @@ import { sql } from 'kysely';
|
|
|
2
2
|
import { parseJson } from './kysely-json.js';
|
|
3
3
|
// Owner ids routinely contain `_` (e.g. `user_123`), which LIKE treats as a
|
|
4
4
|
// single-character wildcard — so a raw prefix would match a foreign owner.
|
|
5
|
-
const escapeLikePattern = (value) => value.replace(/[
|
|
5
|
+
const escapeLikePattern = (value) => value.replace(/[!%_]/g, (char) => `!${char}`);
|
|
6
6
|
export class KyselyAgentRunService {
|
|
7
7
|
db;
|
|
8
8
|
constructor(db) {
|
|
@@ -37,7 +37,7 @@ export class KyselyAgentRunService {
|
|
|
37
37
|
if (owners) {
|
|
38
38
|
query = query.where((eb) => eb.or(owners.flatMap((owner) => [
|
|
39
39
|
eb('t.resourceId', '=', owner),
|
|
40
|
-
eb('t.resourceId', 'like', sql `${`${escapeLikePattern(owner)}:%`} escape '
|
|
40
|
+
eb('t.resourceId', 'like', sql `${`${escapeLikePattern(owner)}:%`} escape '!'`),
|
|
41
41
|
])));
|
|
42
42
|
}
|
|
43
43
|
const result = await query
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { parseJson } from './kysely-json.js';
|
|
2
2
|
import { getRunScores, saveRunScore } from './kysely-agent-run-scores.js';
|
|
3
3
|
import { requirePikkuSchema } from './schema/index.js';
|
|
4
|
+
import { upsert } from './kysely-upsert.js';
|
|
4
5
|
import { agentSchema } from './schema/agent.schema.js';
|
|
5
6
|
export class KyselyAgentStorageService {
|
|
6
7
|
db;
|
|
@@ -216,6 +217,8 @@ export class KyselyAgentStorageService {
|
|
|
216
217
|
if (!toolMsg.toolResults)
|
|
217
218
|
continue;
|
|
218
219
|
for (const tr of toolMsg.toolResults) {
|
|
220
|
+
if (tr.result === undefined)
|
|
221
|
+
continue;
|
|
219
222
|
await this.db
|
|
220
223
|
.updateTable('agentToolCall')
|
|
221
224
|
.set({ result: tr.result })
|
|
@@ -241,19 +244,12 @@ export class KyselyAgentStorageService {
|
|
|
241
244
|
return parseJson(row.data);
|
|
242
245
|
}
|
|
243
246
|
async saveWorkingMemory(id, scope, data) {
|
|
244
|
-
await this.db
|
|
245
|
-
.insertInto('agentWorkingMemory')
|
|
246
|
-
.values({
|
|
247
|
+
await upsert(this.db, this.db.insertInto('agentWorkingMemory').values({
|
|
247
248
|
id,
|
|
248
249
|
scope,
|
|
249
250
|
data: JSON.stringify(data),
|
|
250
251
|
updatedAt: new Date(),
|
|
251
|
-
})
|
|
252
|
-
.onConflict((oc) => oc.columns(['id', 'scope']).doUpdateSet({
|
|
253
|
-
data: JSON.stringify(data),
|
|
254
|
-
updatedAt: new Date(),
|
|
255
|
-
}))
|
|
256
|
-
.execute();
|
|
252
|
+
}), ['id', 'scope'], { data: JSON.stringify(data), updatedAt: new Date() }).execute();
|
|
257
253
|
}
|
|
258
254
|
async createRun(run) {
|
|
259
255
|
const runId = crypto.randomUUID();
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { requirePikkuSchema } from './schema/index.js';
|
|
2
|
+
import { insertOrIgnore } from './kysely-upsert.js';
|
|
2
3
|
import { analyticsSchema } from './schema/analytics.schema.js';
|
|
3
4
|
const jsonOrNull = (value) => value != null ? JSON.stringify(value) : null;
|
|
4
5
|
/**
|
|
@@ -71,10 +72,6 @@ export class KyselyAnalyticsService {
|
|
|
71
72
|
consent: jsonOrNull(record.userIdentity?.consent),
|
|
72
73
|
props: jsonOrNull(record.props),
|
|
73
74
|
}));
|
|
74
|
-
await this.db
|
|
75
|
-
.insertInto('pikkuAnalyticsEvents')
|
|
76
|
-
.values(rows)
|
|
77
|
-
.onConflict((oc) => oc.doNothing())
|
|
78
|
-
.execute();
|
|
75
|
+
await insertOrIgnore(this.db, this.db.insertInto('pikkuAnalyticsEvents').values(rows)).execute();
|
|
79
76
|
}
|
|
80
77
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { requirePikkuSchema } from './schema/index.js';
|
|
2
|
+
import { insertOrIgnore } from './kysely-upsert.js';
|
|
2
3
|
import { auditSchema } from './schema/audit.schema.js';
|
|
3
4
|
const jsonOrNull = (v) => v != null ? JSON.stringify(v) : null;
|
|
4
5
|
const parseJson = (v) => {
|
|
@@ -122,11 +123,7 @@ export class KyselyAuditService {
|
|
|
122
123
|
data: jsonOrNull(e.metadata),
|
|
123
124
|
};
|
|
124
125
|
});
|
|
125
|
-
await this.db
|
|
126
|
-
.insertInto('audit')
|
|
127
|
-
.values(rows)
|
|
128
|
-
.onConflict((oc) => oc.doNothing())
|
|
129
|
-
.execute();
|
|
126
|
+
await insertOrIgnore(this.db, this.db.insertInto('audit').values(rows)).execute();
|
|
130
127
|
}
|
|
131
128
|
/**
|
|
132
129
|
* A page of the trail, newest first.
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { deriveKEK, envelopeDecrypt, envelopeEncrypt, envelopeRewrap, generateKEKSalt, } from '@pikku/core/crypto-utils';
|
|
2
2
|
import { requirePikkuSchema } from './schema/index.js';
|
|
3
|
+
import { insertOrIgnore, timestampParam } from './kysely-upsert.js';
|
|
3
4
|
import { credentialSchema } from './schema/credential.schema.js';
|
|
4
5
|
export class KyselyCredentialService {
|
|
5
6
|
db;
|
|
@@ -37,7 +38,7 @@ export class KyselyCredentialService {
|
|
|
37
38
|
credentialName: name,
|
|
38
39
|
userId: userId ?? null,
|
|
39
40
|
action,
|
|
40
|
-
performedAt:
|
|
41
|
+
performedAt: timestampParam(this.db),
|
|
41
42
|
})
|
|
42
43
|
.execute();
|
|
43
44
|
}
|
|
@@ -52,15 +53,11 @@ export class KyselyCredentialService {
|
|
|
52
53
|
.executeTakeFirst();
|
|
53
54
|
let salt = existing?.salt;
|
|
54
55
|
if (!salt) {
|
|
55
|
-
await this.db
|
|
56
|
-
.insertInto('credentialKekSalts')
|
|
57
|
-
.values({
|
|
56
|
+
await insertOrIgnore(this.db, this.db.insertInto('credentialKekSalts').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('credentialKekSalts')
|
|
66
63
|
.select('salt')
|
|
@@ -110,7 +107,7 @@ export class KyselyCredentialService {
|
|
|
110
107
|
async set(name, value, userId) {
|
|
111
108
|
const plaintext = JSON.stringify(value);
|
|
112
109
|
const { ciphertext, wrappedDEK } = await envelopeEncrypt(await this.getKEK(this.keyVersion), plaintext);
|
|
113
|
-
const now =
|
|
110
|
+
const now = timestampParam(this.db);
|
|
114
111
|
const exists = await this.has(name, userId);
|
|
115
112
|
if (exists) {
|
|
116
113
|
let qb = this.db
|
|
@@ -212,7 +209,7 @@ export class KyselyCredentialService {
|
|
|
212
209
|
.set({
|
|
213
210
|
wrappedDek: newWrappedDEK,
|
|
214
211
|
keyVersion: this.keyVersion,
|
|
215
|
-
updatedAt:
|
|
212
|
+
updatedAt: timestampParam(this.db),
|
|
216
213
|
})
|
|
217
214
|
.where('name', '=', row.name);
|
|
218
215
|
qb = this.whereUserId(qb, row.userId ?? undefined);
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { buildRemoteHeaders } from '@pikku/core/remote';
|
|
2
2
|
import { getAllFunctionNames } from '@pikku/core/function';
|
|
3
3
|
import { requirePikkuSchema } from './schema/index.js';
|
|
4
|
+
import { upsert } from './kysely-upsert.js';
|
|
4
5
|
import { deploymentSchema } from './schema/deployment.schema.js';
|
|
5
6
|
export class KyselyDeploymentService {
|
|
6
7
|
db;
|
|
@@ -28,18 +29,11 @@ export class KyselyDeploymentService {
|
|
|
28
29
|
const functions = config.functions ?? getAllFunctionNames();
|
|
29
30
|
this.deploymentConfig = { ...config, functions };
|
|
30
31
|
await this.db.transaction().execute(async (trx) => {
|
|
31
|
-
await trx
|
|
32
|
-
.insertInto('pikkuDeployments')
|
|
33
|
-
.values({
|
|
32
|
+
await upsert(trx, trx.insertInto('pikkuDeployments').values({
|
|
34
33
|
deploymentId: config.deploymentId,
|
|
35
34
|
endpoint: config.endpoint,
|
|
36
35
|
lastHeartbeat: new Date(),
|
|
37
|
-
})
|
|
38
|
-
.onConflict((oc) => oc.column('deploymentId').doUpdateSet({
|
|
39
|
-
endpoint: config.endpoint,
|
|
40
|
-
lastHeartbeat: new Date(),
|
|
41
|
-
}))
|
|
42
|
-
.execute();
|
|
36
|
+
}), ['deploymentId'], { endpoint: config.endpoint, lastHeartbeat: new Date() }).execute();
|
|
43
37
|
await trx
|
|
44
38
|
.deleteFrom('pikkuDeploymentFunctions')
|
|
45
39
|
.where('deploymentId', '=', config.deploymentId)
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { EventHubStore } from '@pikku/core/channel';
|
|
2
|
+
import { insertOrIgnore } from './kysely-upsert.js';
|
|
2
3
|
export class KyselyEventHubStore extends EventHubStore {
|
|
3
4
|
db;
|
|
4
5
|
initialized = false;
|
|
@@ -22,11 +23,9 @@ export class KyselyEventHubStore extends EventHubStore {
|
|
|
22
23
|
}
|
|
23
24
|
async subscribe(topic, channelId) {
|
|
24
25
|
try {
|
|
25
|
-
await this.db
|
|
26
|
+
await insertOrIgnore(this.db, this.db
|
|
26
27
|
.insertInto('channelSubscriptions')
|
|
27
|
-
.values({ channelId: channelId, topic: topic })
|
|
28
|
-
.onConflict((oc) => oc.columns(['channelId', 'topic']).doNothing())
|
|
29
|
-
.execute();
|
|
28
|
+
.values({ channelId: channelId, topic: topic }), ['channelId', 'topic']).execute();
|
|
30
29
|
return true;
|
|
31
30
|
}
|
|
32
31
|
catch {
|
|
@@ -20,9 +20,9 @@ export declare class KyselyFeatureFlagStore extends CachedFlagSource implements
|
|
|
20
20
|
syncFlags(flags: DeclaredFlag[]): Promise<void>;
|
|
21
21
|
listFlags(): Promise<FlagRow[]>;
|
|
22
22
|
listOverrides(key: string): Promise<FlagOverrideRow[]>;
|
|
23
|
-
setEnabled(key: string, enabled: boolean,
|
|
24
|
-
setRollout(key: string, percent: number | null,
|
|
25
|
-
setOverride(key: string, subject: FlagSubject, enabled: boolean,
|
|
23
|
+
setEnabled(key: string, enabled: boolean, virtualUser?: string, note?: string): Promise<void>;
|
|
24
|
+
setRollout(key: string, percent: number | null, virtualUser?: string): Promise<void>;
|
|
25
|
+
setOverride(key: string, subject: FlagSubject, enabled: boolean, virtualUser?: string): Promise<void>;
|
|
26
26
|
clearOverride(key: string, subject: FlagSubject): Promise<void>;
|
|
27
27
|
findStaleFlags(): Promise<string[]>;
|
|
28
28
|
/**
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { CachedFlagSource, subjectIdOf } from '@pikku/core/flag';
|
|
2
2
|
import { requirePikkuSchema } from './schema/index.js';
|
|
3
|
+
import { INSERTED, isMysql, upsert } from './kysely-upsert.js';
|
|
3
4
|
import { flagSchema } from './schema/flag.schema.js';
|
|
4
5
|
/**
|
|
5
6
|
* Reads the stored `anyOf`, which arrives as either shape.
|
|
@@ -77,25 +78,16 @@ export class KyselyFeatureFlagStore extends CachedFlagSource {
|
|
|
77
78
|
this.setDeclared(flags);
|
|
78
79
|
await this.db.transaction().execute(async (trx) => {
|
|
79
80
|
for (const flag of flags) {
|
|
80
|
-
await trx
|
|
81
|
-
.insertInto('pikkuFeatureFlags')
|
|
82
|
-
.values({
|
|
83
|
-
name: flag.name,
|
|
84
|
-
description: flag.description ?? null,
|
|
85
|
-
anyOf: flag.anyOf ? JSON.stringify(flag.anyOf) : null,
|
|
86
|
-
declared: true,
|
|
87
|
-
})
|
|
88
|
-
.onConflict((oc) =>
|
|
89
81
|
// `enabled`, `rolloutPercent` and every override are the operator's
|
|
90
82
|
// and are never touched here. A deploy that re-enabled a flag
|
|
91
83
|
// somebody killed an hour earlier would make the kill switch
|
|
92
84
|
// useless exactly when it is being relied on.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
85
|
+
await upsert(trx, trx.insertInto('pikkuFeatureFlags').values({
|
|
86
|
+
name: flag.name,
|
|
87
|
+
description: flag.description ?? null,
|
|
88
|
+
anyOf: flag.anyOf ? JSON.stringify(flag.anyOf) : null,
|
|
96
89
|
declared: true,
|
|
97
|
-
})))
|
|
98
|
-
.execute();
|
|
90
|
+
}), ['name'], { description: INSERTED, anyOf: INSERTED, declared: true }).execute();
|
|
99
91
|
}
|
|
100
92
|
const markStale = trx
|
|
101
93
|
.updateTable('pikkuFeatureFlags')
|
|
@@ -140,12 +132,12 @@ export class KyselyFeatureFlagStore extends CachedFlagSource {
|
|
|
140
132
|
: undefined,
|
|
141
133
|
}));
|
|
142
134
|
}
|
|
143
|
-
async setEnabled(key, enabled,
|
|
135
|
+
async setEnabled(key, enabled, virtualUser, note) {
|
|
144
136
|
await this.db
|
|
145
137
|
.updateTable('pikkuFeatureFlags')
|
|
146
138
|
.set({
|
|
147
139
|
enabled,
|
|
148
|
-
updatedBy:
|
|
140
|
+
updatedBy: virtualUser ?? null,
|
|
149
141
|
note: note ?? null,
|
|
150
142
|
updatedAt: new Date(),
|
|
151
143
|
})
|
|
@@ -153,7 +145,7 @@ export class KyselyFeatureFlagStore extends CachedFlagSource {
|
|
|
153
145
|
.execute();
|
|
154
146
|
this.invalidate();
|
|
155
147
|
}
|
|
156
|
-
async setRollout(key, percent,
|
|
148
|
+
async setRollout(key, percent, virtualUser) {
|
|
157
149
|
if (percent !== null && (percent < 0 || percent > 100)) {
|
|
158
150
|
throw new Error(`Feature flag '${key}': rollout must be between 0 and 100, got ${percent}.`);
|
|
159
151
|
}
|
|
@@ -161,38 +153,34 @@ export class KyselyFeatureFlagStore extends CachedFlagSource {
|
|
|
161
153
|
.updateTable('pikkuFeatureFlags')
|
|
162
154
|
.set({
|
|
163
155
|
rolloutPercent: percent === null ? null : Math.round(percent),
|
|
164
|
-
updatedBy:
|
|
156
|
+
updatedBy: virtualUser ?? null,
|
|
165
157
|
updatedAt: new Date(),
|
|
166
158
|
})
|
|
167
159
|
.where('name', '=', key)
|
|
168
160
|
.execute();
|
|
169
161
|
this.invalidate();
|
|
170
162
|
}
|
|
171
|
-
async setOverride(key, subject, enabled,
|
|
163
|
+
async setOverride(key, subject, enabled, virtualUser) {
|
|
172
164
|
const subjectId = subjectIdOf(subject);
|
|
173
165
|
if (!subjectId) {
|
|
174
166
|
throw new Error(`Feature flag '${key}': an override needs an organization or a user to be about.`);
|
|
175
167
|
}
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
168
|
+
// `grantedAt` is rewritten too: the column defaults on insert only, and the
|
|
169
|
+
// panel reads it as when the pin was last granted — not when the subject
|
|
170
|
+
// was first pinned to something else.
|
|
171
|
+
await upsert(this.db, this.db.insertInto('pikkuFeatureFlagOverrides').values({
|
|
179
172
|
flag: key,
|
|
180
173
|
subjectId,
|
|
181
174
|
subjectKind: subject.organizationId ? 'organization' : 'user',
|
|
182
175
|
enabled,
|
|
183
|
-
grantedBy:
|
|
176
|
+
grantedBy: virtualUser ?? null,
|
|
184
177
|
grantedAt: new Date(),
|
|
185
|
-
})
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
// when the pin was last granted — not when the subject was first
|
|
192
|
-
// pinned to something else.
|
|
193
|
-
grantedAt: eb.ref('excluded.grantedAt'),
|
|
194
|
-
})))
|
|
195
|
-
.execute();
|
|
178
|
+
}), ['flag', 'subjectId'], {
|
|
179
|
+
enabled: INSERTED,
|
|
180
|
+
subjectKind: INSERTED,
|
|
181
|
+
grantedBy: INSERTED,
|
|
182
|
+
grantedAt: INSERTED,
|
|
183
|
+
}).execute();
|
|
196
184
|
this.invalidate();
|
|
197
185
|
}
|
|
198
186
|
async clearOverride(key, subject) {
|
|
@@ -225,15 +213,33 @@ export class KyselyFeatureFlagStore extends CachedFlagSource {
|
|
|
225
213
|
* live flag and cascade away every override an operator had set on it.
|
|
226
214
|
*/
|
|
227
215
|
async pruneFlags() {
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
216
|
+
// MySQL has no `returning`: the matching names are read under a lock in the
|
|
217
|
+
// same transaction as the delete, so what is reported is what was deleted.
|
|
218
|
+
const names = await this.db.transaction().execute(async (trx) => {
|
|
219
|
+
if (isMysql(trx)) {
|
|
220
|
+
const rows = await trx
|
|
221
|
+
.selectFrom('pikkuFeatureFlags')
|
|
222
|
+
.select('name')
|
|
223
|
+
.where('declared', '=', false)
|
|
224
|
+
.forUpdate()
|
|
225
|
+
.execute();
|
|
226
|
+
await trx
|
|
227
|
+
.deleteFrom('pikkuFeatureFlags')
|
|
228
|
+
.where('declared', '=', false)
|
|
229
|
+
.execute();
|
|
230
|
+
return rows.map((row) => row.name);
|
|
231
|
+
}
|
|
232
|
+
const deleted = await trx
|
|
233
|
+
.deleteFrom('pikkuFeatureFlags')
|
|
234
|
+
.where('declared', '=', false)
|
|
235
|
+
.returning('name')
|
|
236
|
+
.execute();
|
|
237
|
+
return deleted.map((row) => row.name);
|
|
238
|
+
});
|
|
239
|
+
if (names.length === 0) {
|
|
234
240
|
return [];
|
|
235
241
|
}
|
|
236
242
|
this.invalidate();
|
|
237
|
-
return
|
|
243
|
+
return names.sort();
|
|
238
244
|
}
|
|
239
245
|
}
|
|
@@ -9,6 +9,6 @@ import { type RawBuilder } from 'kysely';
|
|
|
9
9
|
* worker shares. This default binds the process clock, for SQLite, where the
|
|
10
10
|
* database lives in the process anyway.
|
|
11
11
|
*/
|
|
12
|
-
export declare const appNowMs: () => RawBuilder<number>;
|
|
12
|
+
export declare const appNowMs: (mysql?: boolean) => RawBuilder<number>;
|
|
13
13
|
/** `now + ms`, for a lease that runs `ms` from the clock's now. */
|
|
14
14
|
export declare const leaseUntil: (now: RawBuilder<number>, ms: number) => RawBuilder<number>;
|
|
@@ -9,6 +9,10 @@ import { sql } from 'kysely';
|
|
|
9
9
|
* worker shares. This default binds the process clock, for SQLite, where the
|
|
10
10
|
* database lives in the process anyway.
|
|
11
11
|
*/
|
|
12
|
-
export const appNowMs = (
|
|
12
|
+
export const appNowMs = (mysql = false) =>
|
|
13
|
+
// MySQL has no `bigint` to cast to; `signed` is its 64-bit integer.
|
|
14
|
+
mysql
|
|
15
|
+
? sql `cast(${Date.now()} as signed)`
|
|
16
|
+
: sql `cast(${Date.now()} as bigint)`;
|
|
13
17
|
/** `now + ms`, for a lease that runs `ms` from the clock's now. */
|
|
14
18
|
export const leaseUntil = (now, ms) => sql `${now} + ${ms}`;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { appNowMs, leaseUntil } from './kysely-lease-clock.js';
|
|
2
2
|
import { requirePikkuSchema } from './schema/index.js';
|
|
3
|
+
import { insertOrIgnore, isMysql } from './kysely-upsert.js';
|
|
3
4
|
import { leaseSchema } from './schema/lease.schema.js';
|
|
4
5
|
const toLease = (row) => ({
|
|
5
6
|
key: row.key,
|
|
@@ -31,15 +32,13 @@ export class KyselyLeaseService {
|
|
|
31
32
|
}
|
|
32
33
|
/** Now, in epoch milliseconds, on the clock leases are judged by. */
|
|
33
34
|
nowMs() {
|
|
34
|
-
return appNowMs();
|
|
35
|
+
return appNowMs(isMysql(this.db));
|
|
35
36
|
}
|
|
36
37
|
/** Create the key's row, lapsed, unless it already has one. */
|
|
37
38
|
async insertIfAbsent(key, holder) {
|
|
38
|
-
await this.db
|
|
39
|
+
await insertOrIgnore(this.db, this.db
|
|
39
40
|
.insertInto('pikkuLease')
|
|
40
|
-
.values({ key, holder, token: 0, expiresAt: 0 })
|
|
41
|
-
.onConflict((oc) => oc.column('key').doNothing())
|
|
42
|
-
.execute();
|
|
41
|
+
.values({ key, holder, token: 0, expiresAt: 0 }), ['key']).execute();
|
|
43
42
|
}
|
|
44
43
|
/**
|
|
45
44
|
* The row is created lapsed and then taken by the same guarded `UPDATE` as
|