@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.
Files changed (48) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/src/index.d.ts +2 -2
  3. package/dist/src/index.js +2 -2
  4. package/dist/src/kysely-agent-run-service.js +2 -2
  5. package/dist/src/kysely-agent-storage-service.js +5 -9
  6. package/dist/src/kysely-analytics-service.js +2 -5
  7. package/dist/src/kysely-audit-service.js +2 -5
  8. package/dist/src/kysely-credential-service.js +7 -10
  9. package/dist/src/kysely-deployment-service.js +3 -9
  10. package/dist/src/kysely-eventhub-store.js +3 -4
  11. package/dist/src/kysely-feature-flag-store.d.ts +3 -3
  12. package/dist/src/kysely-feature-flag-store.js +47 -41
  13. package/dist/src/kysely-lease-clock.d.ts +1 -1
  14. package/dist/src/kysely-lease-clock.js +5 -1
  15. package/dist/src/kysely-lease-service.js +4 -5
  16. package/dist/src/kysely-scope-service.js +62 -42
  17. package/dist/src/kysely-secret-service.js +10 -17
  18. package/dist/src/kysely-session-store.js +3 -9
  19. package/dist/src/kysely-tables.d.ts +3 -3
  20. package/dist/src/kysely-trigger-source-store.js +3 -4
  21. package/dist/src/kysely-upsert.d.ts +37 -0
  22. package/dist/src/kysely-upsert.js +54 -0
  23. package/dist/src/kysely-virtual-user-run-store.d.ts +1 -1
  24. package/dist/src/kysely-virtual-user-run-store.js +7 -6
  25. package/dist/src/kysely-virtual-user-schedule-store.d.ts +4 -4
  26. package/dist/src/kysely-virtual-user-schedule-store.js +16 -19
  27. package/dist/src/kysely-workflow-mirror.js +2 -1
  28. package/dist/src/kysely-workflow-service.js +5 -1
  29. package/dist/src/schema/index.d.ts +11 -0
  30. package/dist/src/schema/index.js +29 -0
  31. package/dist/src/schema/virtual-user-schedule.schema.d.ts +3 -3
  32. package/dist/src/schema/virtual-user-schedule.schema.js +5 -5
  33. package/dist/src/schema/virtual-user.schema.d.ts +3 -3
  34. package/dist/src/schema/virtual-user.schema.js +9 -9
  35. package/knowledge/decisions/a-console-role-cannot-take-a-system-roles-name.md +25 -0
  36. package/knowledge/decisions/a-credential-with-no-user-is-one-row-per-name.md +22 -0
  37. package/knowledge/decisions/a-virtual-users-schedule-starts-disabled.md +23 -0
  38. package/knowledge/decisions/classification-driven-encryption-happens-in-a-plugin.md +1 -1
  39. package/knowledge/decisions/grants-reference-declared-scopes-and-users-by-foreign-key.md +29 -0
  40. package/knowledge/decisions/index.md +5 -2
  41. package/knowledge/decisions/the-secrets-audit-table-is-declared-even-when-auditing-is-off.md +22 -0
  42. package/knowledge/index.md +1 -0
  43. package/knowledge/questions/do-the-kysely-stores-check-who-calls-them.md +27 -0
  44. package/knowledge/questions/does-a-trigger-source-row-hold-only-provider-identifiers.md +26 -0
  45. package/knowledge/questions/index.md +17 -0
  46. package/knowledge/questions/should-pikku-user-sessions-expire-on-their-own.md +21 -0
  47. package/knowledge/questions/should-secret-and-credential-reads-be-audited-by-default.md +32 -0
  48. 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
@@ -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(/[\\%_]/g, (char) => `\\${char}`);
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: new Date().toISOString(),
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: 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('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 = new Date().toISOString();
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: new Date().toISOString(),
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, actor?: string, note?: string): Promise<void>;
24
- setRollout(key: string, percent: number | null, actor?: string): Promise<void>;
25
- setOverride(key: string, subject: FlagSubject, enabled: boolean, actor?: string): Promise<void>;
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
- oc.column('name').doUpdateSet((eb) => ({
94
- description: eb.ref('excluded.description'),
95
- anyOf: eb.ref('excluded.anyOf'),
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, actor, note) {
135
+ async setEnabled(key, enabled, virtualUser, note) {
144
136
  await this.db
145
137
  .updateTable('pikkuFeatureFlags')
146
138
  .set({
147
139
  enabled,
148
- updatedBy: actor ?? null,
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, actor) {
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: actor ?? null,
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, actor) {
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
- await this.db
177
- .insertInto('pikkuFeatureFlagOverrides')
178
- .values({
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: actor ?? null,
176
+ grantedBy: virtualUser ?? null,
184
177
  grantedAt: new Date(),
185
- })
186
- .onConflict((oc) => oc.columns(['flag', 'subjectId']).doUpdateSet((eb) => ({
187
- enabled: eb.ref('excluded.enabled'),
188
- subjectKind: eb.ref('excluded.subjectKind'),
189
- grantedBy: eb.ref('excluded.grantedBy'),
190
- // The column defaults on insert only, and the panel reads this as
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
- const deleted = await this.db
229
- .deleteFrom('pikkuFeatureFlags')
230
- .where('declared', '=', false)
231
- .returning('name')
232
- .execute();
233
- if (deleted.length === 0) {
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 deleted.map((row) => row.name).sort();
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 = () => sql `cast(${Date.now()} as bigint)`;
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