@pikku/kysely 0.13.34 → 0.13.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +82 -0
  2. package/dist/bin/pikku-kysely-pure.js +4 -1
  3. package/dist/src/classification-crypto.js +16 -2
  4. package/dist/src/index.d.ts +2 -2
  5. package/dist/src/index.js +2 -2
  6. package/dist/src/kysely-agent-run-service.js +2 -2
  7. package/dist/src/kysely-agent-storage-service.js +5 -9
  8. package/dist/src/kysely-analytics-service.js +2 -5
  9. package/dist/src/kysely-audit-service.js +2 -5
  10. package/dist/src/kysely-credential-service.js +7 -10
  11. package/dist/src/kysely-deployment-service.js +7 -10
  12. package/dist/src/kysely-eventhub-store.js +3 -4
  13. package/dist/src/kysely-feature-flag-store.d.ts +3 -3
  14. package/dist/src/kysely-feature-flag-store.js +47 -41
  15. package/dist/src/kysely-lease-clock.d.ts +1 -1
  16. package/dist/src/kysely-lease-clock.js +5 -1
  17. package/dist/src/kysely-lease-service.js +4 -5
  18. package/dist/src/kysely-scope-service.js +62 -42
  19. package/dist/src/kysely-secret-service.js +10 -17
  20. package/dist/src/kysely-session-store.js +3 -9
  21. package/dist/src/kysely-tables.d.ts +3 -3
  22. package/dist/src/kysely-trigger-source-store.js +3 -4
  23. package/dist/src/kysely-upsert.d.ts +37 -0
  24. package/dist/src/kysely-upsert.js +54 -0
  25. package/dist/src/kysely-virtual-user-run-store.d.ts +1 -1
  26. package/dist/src/kysely-virtual-user-run-store.js +8 -8
  27. package/dist/src/kysely-virtual-user-schedule-store.d.ts +4 -4
  28. package/dist/src/kysely-virtual-user-schedule-store.js +16 -19
  29. package/dist/src/kysely-workflow-mirror.js +2 -1
  30. package/dist/src/kysely-workflow-service.js +5 -1
  31. package/dist/src/schema/index.d.ts +11 -0
  32. package/dist/src/schema/index.js +29 -0
  33. package/dist/src/schema/virtual-user-schedule.schema.d.ts +3 -3
  34. package/dist/src/schema/virtual-user-schedule.schema.js +5 -5
  35. package/dist/src/schema/virtual-user.schema.d.ts +3 -3
  36. package/dist/src/schema/virtual-user.schema.js +9 -9
  37. package/knowledge/decisions/a-console-role-cannot-take-a-system-roles-name.md +25 -0
  38. package/knowledge/decisions/a-credential-with-no-user-is-one-row-per-name.md +22 -0
  39. package/knowledge/decisions/a-virtual-users-schedule-starts-disabled.md +23 -0
  40. package/knowledge/decisions/classification-driven-encryption-happens-in-a-plugin.md +1 -1
  41. package/knowledge/decisions/grants-reference-declared-scopes-and-users-by-foreign-key.md +29 -0
  42. package/knowledge/decisions/index.md +5 -2
  43. package/knowledge/decisions/the-secrets-audit-table-is-declared-even-when-auditing-is-off.md +22 -0
  44. package/knowledge/index.md +1 -0
  45. package/knowledge/questions/do-the-kysely-stores-check-who-calls-them.md +27 -0
  46. package/knowledge/questions/does-a-trigger-source-row-hold-only-provider-identifiers.md +26 -0
  47. package/knowledge/questions/index.md +17 -0
  48. package/knowledge/questions/should-pikku-user-sessions-expire-on-their-own.md +21 -0
  49. package/knowledge/questions/should-secret-and-credential-reads-be-audited-by-default.md +32 -0
  50. package/package.json +3 -2
package/CHANGELOG.md CHANGED
@@ -1,3 +1,85 @@
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
+
50
+ ## 0.13.35
51
+
52
+ ### Patch Changes
53
+
54
+ - 15c4968: The default runtime path no longer needs `node:*`, `Buffer` or an unguarded `process`, so it loads on edge runtimes (Web APIs only).
55
+
56
+ **Breaking:** `FileScenarioRunStore`, `scenarioArtifactContentType` and `scenarioRunSummary` leave the `@pikku/core/services` barrel because they use `node:fs`. Import them from `@pikku/core/services/file-scenario-run-store`. Pre-0.13 breaking changes still ship as a patch.
57
+
58
+ **Breaking:** the HMAC helpers in `@pikku/core/hmac` run on Web Crypto, which is async. `hmacSha256Hex`, `hmacDigest`, `verifyHmacSignature` and `verifyPublicKeySignature` now return a Promise; `timingSafeStringEqual` stays synchronous. `WebhookService.sign` and `WebhookService.verify` return a Promise, and so does `verifySlackSignature` in `@pikku/gateway-slack`: `await` them. A function-style `verify` on a webhook source may return a Promise as well. `verifyPublicKeySignature` accepts RSA and EC (P-256, P-384, P-521) PEM keys; Ed25519 and RSA-PSS keys verify as false.
59
+
60
+ `isProduction()` (and the `PIKKU_MODEL_ALIASES` and `PIKKU_ENV` reads) now ask the registered singleton services' `variables` service first, then `process.env` when `process` exists. With neither, `isProduction()` is true, so an edge deployment never exposes error details by accident. `LocalVariablesService` resolves its `process.env` default when constructed and falls back to an empty store without `process`.
61
+
62
+ Workflow invocation ids use a built-in SHA-1 and stay byte-identical. `@pikku/kysely` and the Cloudflare hibernation server use `globalThis.crypto.randomUUID()` and no `Buffer`; `@pikku/kysely` is marked `sideEffects: false`. `@pikku/ai-deepinfra` reads `DEEPINFRA_API_KEY` only when `process` exists and decodes base64 audio without `Buffer`.
63
+
64
+ Runtime tiers. A package declares where it can run in `package.json`: `"pikku": { "runtime": "edge" | "serverless" | "server" }`, with optional per-subpath overrides (`"exports": { "./dev": "server" }`). `server` is the default. `@pikku/core` and the default services are `edge` (core's Node-only subpaths are `server`), `@pikku/better-auth`, `@pikku/addon-admin` and `@pikku/cloudflare` are `serverless` (better-auth statically imports `node:crypto` scrypt), and `@pikku/addon-console` is `server` and must never land in a deployed unit.
65
+
66
+ `pikku deploy` now verifies each deployed unit before bundling it. The unit is bundled with esbuild (neutral platform, `workerd`/`worker`/`browser` conditions) under its tier's profile, ahead of the adapter's aliases and stubs so they cannot hide anything, and the build fails when the bundle holds a package declared above the unit's tier or a Node built-in the runtime does not provide. The error names the unit, package, import chain and the fix. A built-in the provider stubs is a warning. On an edge unit, a bundled `InMemory*` class also fails the build. The unit's tier is its `runtime`, else the project's own `pikku.runtime`, else the provider default (`serverless` on Cloudflare). Packages that declare nothing are judged by their imports only.
67
+
68
+ `ProviderAdapter.getRuntimeProfile(tier?)` returns the compat date, compat flags, the built-ins the runtime provides (keyed by compat date), the stubbed ones and the externals. The Cloudflare adapter derives `wrangler.toml`, the upload metadata and the bundle externals from it; `node:os` and `node:fs` are not provided before compat date 2025-09-15. The default is `nodejs_compat_v2` with compat date 2024-12-18, which is what `wrangler.toml` and Fabric's live workers already use, so Fabric deploys see no runtime change. Only the direct-API upload in `@pikku/deploy-cloudflare` (`createWorker`, used by its own `deploy()`) moves: it sent `nodejs_compat` with 2024-01-01 before. A newer date or flag is opt-in through the adapter option `new CloudflareProviderAdapter({ compatDate })` or `getCloudflareRuntimeProfile(tier, compatDate)`. From 2025-08-15 `node:http` and `node:https` exist, and from 2025-09-15 `node:os`, `node:fs` (stubbed by the build regardless) and `node:perf_hooks`; the verifier's allowed built-ins follow the effective date. A newer date changes the runtime of every worker deployed with it.
69
+
70
+ `declareScopes(['pikku:console'])` (from `@pikku/core/scope`) declares scopes by id without wiring anything. `wireAddon({ scopes })` only requires a scope of an addon's functions; the tree arrives with the addon's metadata, so a role granting `pikku:console` used to fail PKU124 unless the addon was wired, and a wired `@pikku/addon-console` lands in every unit carrying the `/rpc` catch-all. An app that grants the scope without the console now calls `declareScopes` and no longer needs the scaffold or the addon. When the addon is wired its tree and descriptions win and the bare id adds nothing.
71
+
72
+ More packages declare a tier after bundling each with the edge profile. `edge`: `@pikku/addon-graph`, `@pikku/pino`, `@pikku/jose`, `@pikku/ai-vercel`, `@pikku/ai-voice`. `serverless`: `@pikku/kysely-postgres` (postgres.js needs `node:buffer`, `stream`, `events`), `@pikku/backblaze` (`copyFile` reads a local file, which the build stubs), `@pikku/gateway-slack` (`@slack/web-api` imports `node:os` and `zlib`). `server`: `@pikku/schedule` (`cron` imports `child_process`), `@pikku/aws-services` (the AWS SDK's Node handler needs `node:http2`), `@pikku/browser` (puppeteer), and `@pikku/modelcontextprotocol` except its `./fetch` subpath, which is `edge`.
73
+
74
+ `@pikku/addon-graph` no longer needs `node:crypto` or `Buffer`: the Crypto function hashes and HMACs on Web Crypto (MD5 is built in, since Web Crypto has none) and encodes with `TextEncoder`/`atob`/`btoa`, with output unchanged.
75
+
76
+ - Updated dependencies [6f11bb5]
77
+ - Updated dependencies [0c67e33]
78
+ - Updated dependencies [15c4968]
79
+ - Updated dependencies [d75a23d]
80
+ - Updated dependencies [9b12c8a]
81
+ - @pikku/core@0.12.138
82
+
1
83
  ## 0.13.34
2
84
 
3
85
  ### Patch Changes
@@ -39,4 +39,7 @@ if (process.argv.length !== 4) {
39
39
  process.exit(1);
40
40
  }
41
41
  const [, , output, input] = process.argv;
42
- main(output, input);
42
+ main(output, input).catch((err) => {
43
+ console.error(err);
44
+ process.exit(1);
45
+ });
@@ -12,8 +12,22 @@ import { DEFAULT_KEY_ID } from '@pikku/core/classification';
12
12
  */
13
13
  export { DEFAULT_KEY_ID };
14
14
  const ENVELOPE_PREFIX = 'pikku1';
15
- const encodePart = (value) => Buffer.from(value, 'utf8').toString('base64url');
16
- const decodePart = (value) => Buffer.from(value, 'base64url').toString('utf8');
15
+ /** base64url of the UTF-8 bytes, unpadded: `Buffer`-free, so it runs on edge runtimes. */
16
+ const encodePart = (value) => {
17
+ let binary = '';
18
+ for (const byte of new TextEncoder().encode(value)) {
19
+ binary += String.fromCharCode(byte);
20
+ }
21
+ return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
22
+ };
23
+ const decodePart = (value) => {
24
+ const base64 = value.replace(/-/g, '+').replace(/_/g, '/');
25
+ const binary = atob(base64 + '='.repeat((4 - (base64.length % 4)) % 4));
26
+ const bytes = new Uint8Array(binary.length);
27
+ for (let i = 0; i < binary.length; i++)
28
+ bytes[i] = binary.charCodeAt(i);
29
+ return new TextDecoder().decode(bytes);
30
+ };
17
31
  const serializeEnvelope = (envelope) => [
18
32
  ENVELOPE_PREFIX,
19
33
  encodePart(envelope.keyId),
@@ -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)
@@ -54,7 +48,10 @@ export class KyselyDeploymentService {
54
48
  .execute();
55
49
  }
56
50
  });
57
- this.heartbeatTimer = setInterval(() => this.sendHeartbeat(), this.heartbeatInterval);
51
+ this.heartbeatTimer = setInterval(() => {
52
+ // sendHeartbeat catches its own errors; it retries on the next tick
53
+ void this.sendHeartbeat();
54
+ }, this.heartbeatInterval);
58
55
  }
59
56
  async stop() {
60
57
  if (this.heartbeatTimer) {
@@ -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