@pikku/cli 0.12.125 → 0.12.126

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 (176) hide show
  1. package/CHANGELOG.md +166 -0
  2. package/dist/.pikku/addon/index.d.ts +1 -1
  3. package/dist/.pikku/addon/index.js +1 -1
  4. package/dist/.pikku/addon/pikku-addon-types.gen.d.ts +1 -1
  5. package/dist/.pikku/addon/pikku-addon-types.gen.js +1 -1
  6. package/dist/.pikku/agent/index.d.ts +1 -1
  7. package/dist/.pikku/agent/index.js +1 -1
  8. package/dist/.pikku/agent/pikku-agent-types.gen.d.ts +1 -1
  9. package/dist/.pikku/agent/pikku-model-aliases.gen.js +1 -1
  10. package/dist/.pikku/auth/index.d.ts +1 -1
  11. package/dist/.pikku/auth/index.js +1 -1
  12. package/dist/.pikku/auth/pikku-auth-types.gen.d.ts +1 -1
  13. package/dist/.pikku/auth/pikku-auth-types.gen.js +1 -1
  14. package/dist/.pikku/channel/index.d.ts +1 -1
  15. package/dist/.pikku/channel/index.js +1 -1
  16. package/dist/.pikku/channel/pikku-channel-types.gen.d.ts +1 -1
  17. package/dist/.pikku/channel/pikku-channel-types.gen.js +1 -1
  18. package/dist/.pikku/cli/index.d.ts +1 -1
  19. package/dist/.pikku/cli/index.js +1 -1
  20. package/dist/.pikku/cli/pikku-cli-channel.js +6 -1
  21. package/dist/.pikku/cli/pikku-cli-client.gen.d.ts +1 -1
  22. package/dist/.pikku/cli/pikku-cli-client.gen.js +1 -1
  23. package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.d.ts +1 -1
  24. package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.js +1 -1
  25. package/dist/.pikku/cli/pikku-cli-types.gen.d.ts +1 -1
  26. package/dist/.pikku/cli/pikku-cli-types.gen.js +1 -1
  27. package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.js +1 -1
  28. package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.json +13 -0
  29. package/dist/.pikku/cli/pikku-cli-wirings.gen.d.ts +1 -1
  30. package/dist/.pikku/cli/pikku-cli-wirings.gen.js +1 -1
  31. package/dist/.pikku/cli/pikku-cli.gen.d.ts +1 -1
  32. package/dist/.pikku/cli/pikku-cli.gen.js +1 -1
  33. package/dist/.pikku/error/index.d.ts +1 -1
  34. package/dist/.pikku/error/index.js +1 -1
  35. package/dist/.pikku/error/pikku-error-types.gen.d.ts +1 -1
  36. package/dist/.pikku/error/pikku-error-types.gen.js +1 -1
  37. package/dist/.pikku/function/index.d.ts +1 -1
  38. package/dist/.pikku/function/index.js +1 -1
  39. package/dist/.pikku/function/pikku-function-types.gen.d.ts +1 -1
  40. package/dist/.pikku/function/pikku-function-types.gen.js +1 -1
  41. package/dist/.pikku/function/pikku-functions-meta.gen.js +1 -1
  42. package/dist/.pikku/function/pikku-functions-meta.gen.json +35 -18
  43. package/dist/.pikku/function/pikku-functions.gen.js +1 -1
  44. package/dist/.pikku/gateway/index.d.ts +1 -1
  45. package/dist/.pikku/gateway/index.js +1 -1
  46. package/dist/.pikku/gateway/pikku-gateway-types.gen.d.ts +1 -1
  47. package/dist/.pikku/gateway/pikku-gateway-types.gen.js +1 -1
  48. package/dist/.pikku/http/index.d.ts +1 -1
  49. package/dist/.pikku/http/index.js +1 -1
  50. package/dist/.pikku/http/pikku-http-types.gen.d.ts +1 -1
  51. package/dist/.pikku/http/pikku-http-types.gen.js +1 -1
  52. package/dist/.pikku/mcp/index.d.ts +1 -1
  53. package/dist/.pikku/mcp/index.js +1 -1
  54. package/dist/.pikku/mcp/pikku-mcp-types.gen.d.ts +1 -1
  55. package/dist/.pikku/mcp/pikku-mcp-types.gen.js +1 -1
  56. package/dist/.pikku/middleware/index.d.ts +1 -1
  57. package/dist/.pikku/middleware/index.js +1 -1
  58. package/dist/.pikku/middleware/pikku-middleware-types.gen.d.ts +1 -1
  59. package/dist/.pikku/middleware/pikku-middleware-types.gen.js +1 -1
  60. package/dist/.pikku/pikku-bootstrap-scenarios.gen.d.ts +1 -1
  61. package/dist/.pikku/pikku-bootstrap-scenarios.gen.js +1 -1
  62. package/dist/.pikku/pikku-bootstrap.gen.d.ts +1 -1
  63. package/dist/.pikku/pikku-bootstrap.gen.js +1 -1
  64. package/dist/.pikku/pikku-services.gen.d.ts +1 -1
  65. package/dist/.pikku/queue/index.d.ts +1 -1
  66. package/dist/.pikku/queue/index.js +1 -1
  67. package/dist/.pikku/queue/pikku-queue-types.gen.d.ts +1 -1
  68. package/dist/.pikku/queue/pikku-queue-types.gen.js +1 -1
  69. package/dist/.pikku/queue/pikku-queue-workers-wirings-meta.gen.js +1 -1
  70. package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.d.ts +1 -1
  71. package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.js +1 -1
  72. package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.js +1 -1
  73. package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.json +1 -0
  74. package/dist/.pikku/scenarios/index.d.ts +1 -1
  75. package/dist/.pikku/scenarios/index.js +1 -1
  76. package/dist/.pikku/scenarios/pikku-personas.gen.js +1 -1
  77. package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.js +1 -1
  78. package/dist/.pikku/scenarios/pikku-scenario-functions.gen.d.ts +1 -1
  79. package/dist/.pikku/scenarios/pikku-scenario-types.gen.d.ts +1 -1
  80. package/dist/.pikku/scenarios/pikku-scenario-wirings-meta.gen.js +1 -1
  81. package/dist/.pikku/scenarios/pikku-scenario-wirings.gen.d.ts +1 -1
  82. package/dist/.pikku/scenarios/schemas/register.gen.d.ts +1 -1
  83. package/dist/.pikku/scenarios/schemas/register.gen.js +1 -1
  84. package/dist/.pikku/scheduler/index.d.ts +1 -1
  85. package/dist/.pikku/scheduler/index.js +1 -1
  86. package/dist/.pikku/scheduler/pikku-scheduler-types.gen.d.ts +1 -1
  87. package/dist/.pikku/scheduler/pikku-scheduler-types.gen.js +1 -1
  88. package/dist/.pikku/schemas/register.gen.js +3 -1
  89. package/dist/.pikku/schemas/schemas/FabricSecretsListInput.schema.json +1 -1
  90. package/dist/.pikku/schemas/schemas/FabricSecretsRotateInput.schema.json +1 -1
  91. package/dist/.pikku/schemas/schemas/FabricSecretsSetInput.schema.json +1 -1
  92. package/dist/.pikku/schemas/schemas/FabricVariablesGetInput.schema.json +1 -1
  93. package/dist/.pikku/schemas/schemas/FabricVariablesSetInput.schema.json +1 -1
  94. package/dist/.pikku/schemas/schemas/PersonaSecretInput.schema.json +1 -0
  95. package/dist/.pikku/scopes/index.d.ts +1 -1
  96. package/dist/.pikku/scopes/index.js +1 -1
  97. package/dist/.pikku/scopes/pikku-personas.gen.js +1 -1
  98. package/dist/.pikku/scopes/pikku-roles.gen.d.ts +1 -1
  99. package/dist/.pikku/scopes/pikku-scope-types.gen.d.ts +1 -1
  100. package/dist/.pikku/scopes/pikku-scope-types.gen.js +1 -1
  101. package/dist/.pikku/scopes/pikku-scopes.gen.d.ts +1 -1
  102. package/dist/.pikku/secrets/index.d.ts +1 -1
  103. package/dist/.pikku/secrets/index.js +1 -1
  104. package/dist/.pikku/secrets/pikku-secret-types.gen.d.ts +1 -1
  105. package/dist/.pikku/secrets/pikku-secret-types.gen.js +1 -1
  106. package/dist/.pikku/secrets/pikku-secrets.gen.d.ts +1 -1
  107. package/dist/.pikku/secrets/pikku-secrets.gen.js +1 -1
  108. package/dist/.pikku/services/pikku-meta-service.gen.d.ts +1 -1
  109. package/dist/.pikku/services/pikku-meta-service.gen.js +1 -1
  110. package/dist/.pikku/setup/index.d.ts +1 -1
  111. package/dist/.pikku/setup/index.js +1 -1
  112. package/dist/.pikku/setup/pikku-setup-types.gen.d.ts +1 -1
  113. package/dist/.pikku/setup/pikku-setup-types.gen.js +1 -1
  114. package/dist/.pikku/trigger/index.d.ts +1 -1
  115. package/dist/.pikku/trigger/index.js +1 -1
  116. package/dist/.pikku/trigger/pikku-trigger-types.gen.d.ts +1 -1
  117. package/dist/.pikku/trigger/pikku-trigger-types.gen.js +1 -1
  118. package/dist/.pikku/variables/index.d.ts +1 -1
  119. package/dist/.pikku/variables/index.js +1 -1
  120. package/dist/.pikku/variables/pikku-variable-types.gen.d.ts +1 -1
  121. package/dist/.pikku/variables/pikku-variable-types.gen.js +1 -1
  122. package/dist/.pikku/variables/pikku-variables.gen.d.ts +1 -1
  123. package/dist/.pikku/variables/pikku-variables.gen.js +1 -1
  124. package/dist/.pikku/workflow/index.d.ts +1 -1
  125. package/dist/.pikku/workflow/index.js +1 -1
  126. package/dist/.pikku/workflow/pikku-workflow-types.gen.d.ts +1 -1
  127. package/dist/.pikku/workflow/pikku-workflow-types.gen.js +1 -1
  128. package/dist/.pikku/workflow/pikku-workflow-wirings-meta.gen.js +1 -1
  129. package/dist/.pikku/workflow/pikku-workflow-wirings.gen.js +1 -1
  130. package/dist/bin/pikku-bin.mjs +2 -2
  131. package/dist/src/cli.wiring.js +6 -1
  132. package/dist/src/deploy/bundler/bun-bundler.js +12 -5
  133. package/dist/src/fabric/fabric-commands.d.ts +15 -15
  134. package/dist/src/fabric/functions/deploy.function.js +12 -2
  135. package/dist/src/fabric/functions/secrets-list.function.d.ts +4 -4
  136. package/dist/src/fabric/functions/secrets-list.function.js +4 -4
  137. package/dist/src/fabric/functions/secrets-rotate.function.d.ts +4 -4
  138. package/dist/src/fabric/functions/secrets-rotate.function.js +8 -5
  139. package/dist/src/fabric/functions/secrets-set.function.d.ts +4 -4
  140. package/dist/src/fabric/functions/secrets-set.function.js +4 -4
  141. package/dist/src/fabric/functions/validate.function.js +49 -24
  142. package/dist/src/fabric/functions/variables-get.function.d.ts +4 -4
  143. package/dist/src/fabric/functions/variables-get.function.js +4 -4
  144. package/dist/src/fabric/functions/variables-set.function.d.ts +4 -4
  145. package/dist/src/fabric/functions/variables-set.function.js +4 -4
  146. package/dist/src/fabric/lib/blank-comments.d.ts +21 -0
  147. package/dist/src/fabric/lib/blank-comments.js +71 -0
  148. package/dist/src/fabric/lib/blank-scenario-meta.d.ts +1 -0
  149. package/dist/src/fabric/lib/blank-scenario-meta.js +110 -0
  150. package/dist/src/fabric/lib/deployment.d.ts +26 -0
  151. package/dist/src/fabric/lib/deployment.js +35 -0
  152. package/dist/src/fabric/lib/stage.d.ts +19 -3
  153. package/dist/src/fabric/lib/stage.js +29 -6
  154. package/dist/src/functions/commands/db-reset.js +6 -2
  155. package/dist/src/functions/commands/dev.js +5 -1
  156. package/dist/src/functions/commands/persona.d.ts +18 -0
  157. package/dist/src/functions/commands/persona.js +41 -2
  158. package/dist/src/functions/commands/scenario-browser.d.ts +13 -5
  159. package/dist/src/functions/db/check-enums.d.ts +38 -0
  160. package/dist/src/functions/db/check-enums.js +103 -0
  161. package/dist/src/functions/db/db-codegen.js +15 -0
  162. package/dist/src/functions/db/postgres/postgres-introspector.d.ts +14 -0
  163. package/dist/src/functions/db/postgres/postgres-introspector.js +48 -0
  164. package/dist/src/functions/db/sqlite/sqlite-introspector.d.ts +3 -4
  165. package/dist/src/functions/db/sqlite/sqlite-introspector.js +5 -17
  166. package/dist/src/functions/wirings/virtual-user/serialize-virtual-user-functions.d.ts +9 -6
  167. package/dist/src/functions/wirings/virtual-user/serialize-virtual-user-functions.js +51 -27
  168. package/dist/src/server/actor-sign-in.d.ts +24 -1
  169. package/dist/src/server/actor-sign-in.js +45 -2
  170. package/dist/src/utils/persona-credentials.d.ts +23 -2
  171. package/dist/src/utils/persona-credentials.js +55 -2
  172. package/dist/src/utils/pikku-cli-config.d.ts +4 -4
  173. package/dist/src/utils/pikku-cli-config.js +30 -10
  174. package/dist/tsconfig.tsbuildinfo +1 -1
  175. package/package.json +8 -8
  176. package/surface.json +101 -51
@@ -43,13 +43,17 @@ export const dbReset = pikkuSessionlessFunc({
43
43
  logger.info(zod.written
44
44
  ? `db reset: regenerated ${zod.outFile} (${zod.tables.length} tables)`
45
45
  : `db reset: ${zod.outFile} unchanged`);
46
+ // Both lines report on the SEED STEP, never on the database. A migration
47
+ // is free to carry rows — data a deployed stage needs is migration data —
48
+ // so "database is empty" was a verdict this step has no standing to reach,
49
+ // and it read as a failed reset every time it was wrong.
46
50
  if (noSeed) {
47
- logger.info('db reset: --no-seed, leaving the database empty');
51
+ logger.info('db reset: --no-seed, skipping the dev seed');
48
52
  return;
49
53
  }
50
54
  const devSeedResult = await devSeed(resolved);
51
55
  logger.info(devSeedResult.applied
52
56
  ? `db reset: seeded ${resolved.devSeedFile} (${devSeedResult.bytes} bytes)`
53
- : `db reset: no ${resolved.devSeedFile} found, database is empty`);
57
+ : `db reset: no dev seed applied (${resolved.devSeedFile} not found)`);
54
58
  },
55
59
  });
@@ -24,13 +24,17 @@ import { resolveConsoleMount } from './serve-console.js';
24
24
  import { serverReadyLine } from '../../server/server-ready.js';
25
25
  import { createEphemeralContentSigningJWT } from '../../server/content-signing-jwt.js';
26
26
  import { enableDevActorSignIn } from '../../server/actor-sign-in.js';
27
+ import { resolvePersonas } from '../../utils/resolve-personas.js';
27
28
  import { applyModelAliasOverride } from '../../utils/model-alias-override.js';
28
29
  export const dev = pikkuSessionlessFunc({
29
30
  remote: true,
30
31
  func: async ({ logger, config, getInspectorState, invalidateInspectorState, variables, devServerRunner, }, { port, watch, hmr, coverage, test, model }, { rpc }) => {
31
32
  process.env.PIKKU_DEV_QUICK_LOGIN ??= 'true';
32
33
  process.env.PIKKU_ENV ??= resolveDevEnvironmentName(config.environments ?? {});
33
- enableDevActorSignIn(logger);
34
+ await enableDevActorSignIn(logger, async () => {
35
+ const state = await getInspectorState(true, false, false, true);
36
+ return Object.values(resolvePersonas(state.personas?.definitions ?? [], config.scenarios?.emailDomain));
37
+ });
34
38
  applyModelAliasOverride(logger, model, config.models);
35
39
  if (test) {
36
40
  process.env.PIKKU_TEST_RUN = 'true';
@@ -55,4 +55,22 @@ export declare const personaRun: import("#pikku/function").PikkuFunctionConfig<{
55
55
  out?: string;
56
56
  }, void, "rpc" | "session", import("#pikku/function").WiredServices>, undefined, undefined>;
57
57
  /** Who this app is for, and what each of them wants, without running anything. */
58
+ /**
59
+ * Mint the actor credential for one persona, or for every declared persona.
60
+ *
61
+ * The root secret is entitled to all of them, so nothing that only needs to be
62
+ * one person should ever hold it. What comes out is bound to that persona's
63
+ * address by the target — presenting it for anyone else is refused — so a CI
64
+ * job, a container or a virtual user can be handed exactly the people it is
65
+ * meant to be and no more.
66
+ *
67
+ * Printed as `id=secret` lines, which is the shape PIKKU_PERSONA_SECRETS reads.
68
+ */
69
+ export declare const personaSecret: import("#pikku/function").PikkuFunctionConfig<{
70
+ personas?: string[];
71
+ }, void, "rpc" | "session", import("#pikku/function").PikkuFunctionSessionless<{
72
+ personas?: string[];
73
+ }, void, "rpc" | "session", import("#pikku/function").WiredServices, unknown> | import("#pikku/function").PikkuFunction<{
74
+ personas?: string[];
75
+ }, void, "rpc" | "session", import("#pikku/function").WiredServices>, undefined, undefined>;
58
76
  export declare const personaList: import("#pikku/function").PikkuFunctionConfig<void, void, "rpc" | "session", import("#pikku/function").PikkuFunctionSessionless<void, void, "rpc" | "session", import("#pikku/function").WiredServices, unknown> | import("#pikku/function").PikkuFunction<void, void, "rpc" | "session", import("#pikku/function").WiredServices>, undefined, undefined>;
@@ -1,11 +1,11 @@
1
1
  import { resolve, dirname } from 'node:path';
2
2
  import { mkdirSync, writeFileSync } from 'node:fs';
3
3
  import { pikkuSessionlessFunc } from '#pikku/function';
4
- import { createHttpPersonas, personaEnvironmentRefusal, roleMismatchMessage, verifyPersonaRoles, } from '@pikku/core/persona';
4
+ import { createHttpPersonas, deriveActorSecret, personaEnvironmentRefusal, roleMismatchMessage, verifyPersonaRoles, } from '@pikku/core/persona';
5
5
  import { personaVirtualUserTarget, runVirtualUser, catalogueClassification, DISPOSITIONS, prepareVirtualUserRun, } from '@pikku/core/virtual-user';
6
6
  import { getSingletonServices, setSingletonServices } from '@pikku/core/state';
7
7
  import { resolvePersonas } from '../../utils/resolve-personas.js';
8
- import { resolvePersonaCredentials } from '../../utils/persona-credentials.js';
8
+ import { ACTOR_SECRET_VARIABLE, PERSONA_SECRETS_VARIABLE, resolvePersonaCredentials, } from '../../utils/persona-credentials.js';
9
9
  import { resolveEnvironment } from './environment.js';
10
10
  import { createDevAgentRunner } from './dev-agent-runner.js';
11
11
  import { formatVirtualUserReport } from './virtual-user-formatter.js';
@@ -191,6 +191,45 @@ export const personaRun = pikkuSessionlessFunc({
191
191
  },
192
192
  });
193
193
  /** Who this app is for, and what each of them wants, without running anything. */
194
+ /**
195
+ * Mint the actor credential for one persona, or for every declared persona.
196
+ *
197
+ * The root secret is entitled to all of them, so nothing that only needs to be
198
+ * one person should ever hold it. What comes out is bound to that persona's
199
+ * address by the target — presenting it for anyone else is refused — so a CI
200
+ * job, a container or a virtual user can be handed exactly the people it is
201
+ * meant to be and no more.
202
+ *
203
+ * Printed as `id=secret` lines, which is the shape PIKKU_PERSONA_SECRETS reads.
204
+ */
205
+ export const personaSecret = pikkuSessionlessFunc({
206
+ func: async ({ logger, config, variables, getInspectorState }, input) => {
207
+ const root = await variables.get(ACTOR_SECRET_VARIABLE);
208
+ if (!root) {
209
+ throw new Error(`${ACTOR_SECRET_VARIABLE} is not set — there is no root to derive from. ` +
210
+ `It lives in the environment of whoever mints credentials, and never in pikku.config.json.`);
211
+ }
212
+ const state = await getInspectorState(true, false, false, true);
213
+ const declared = resolvePersonas(state.personas?.definitions ?? [], config.scenarios?.emailDomain);
214
+ const wanted = input.personas?.length
215
+ ? input.personas
216
+ : Object.keys(declared);
217
+ const unknown = wanted.filter((id) => !declared[id]);
218
+ if (unknown.length > 0) {
219
+ throw new Error(`No persona ${unknown.map((id) => `'${id}'`).join(', ')} is declared. ` +
220
+ `Declared: ${Object.keys(declared).sort().join(', ') || 'none'}.`);
221
+ }
222
+ const minted = [];
223
+ for (const id of wanted) {
224
+ minted.push(`${id}=${await deriveActorSecret(String(root), declared[id].email)}`);
225
+ }
226
+ for (const line of minted) {
227
+ logger.info(line);
228
+ }
229
+ logger.info('');
230
+ logger.info(`Hand these to a run as ${PERSONA_SECRETS_VARIABLE}='${minted.join(',')}'`);
231
+ },
232
+ });
194
233
  export const personaList = pikkuSessionlessFunc({
195
234
  func: async ({ logger, config, getInspectorState }) => {
196
235
  const state = await getInspectorState(true, false, false, true);
@@ -9,12 +9,16 @@
9
9
  */
10
10
  import type { ScenarioArtifact, ScenarioBrowserFailure, ScenarioBrowserProvider } from '@pikku/core/scenario';
11
11
  import type { ResolvedPersona } from '@pikku/core/services';
12
- import type { OperatorSignInOptions } from '@pikku/core/persona';
12
+ import type { ActorSecretResolver, OperatorSignInOptions } from '@pikku/core/persona';
13
13
  /** The default driver, used when a project names no other. */
14
14
  export declare const DEFAULT_BROWSER_DRIVER = "@pikku/playwright";
15
15
  export interface ScenarioBrowserDriverOptions {
16
- /** The actor secret, for a local `pikku dev` target. */
17
- secret?: string;
16
+ /**
17
+ * The root actor secret, for a local `pikku dev` target — each persona's
18
+ * credential is derived from it. A resolver instead when this run was handed
19
+ * credentials for only some personas.
20
+ */
21
+ secret?: string | ActorSecretResolver;
18
22
  /** Fabric operator credentials, for a deployed target. */
19
23
  operator?: OperatorSignInOptions;
20
24
  actors: Record<string, ResolvedPersona>;
@@ -72,8 +76,12 @@ export interface ResolveScenarioBrowserProviderOptions {
72
76
  appUrl?: string;
73
77
  /** Base url per app, for a product whose personas sign into more than one. */
74
78
  appUrls?: Record<string, string>;
75
- /** The actor secret, for a local `pikku dev` target. */
76
- secret?: string;
79
+ /**
80
+ * The root actor secret, for a local `pikku dev` target — each persona's
81
+ * credential is derived from it. A resolver instead when this run was handed
82
+ * credentials for only some personas.
83
+ */
84
+ secret?: string | ActorSecretResolver;
77
85
  /** Fabric operator credentials, for a deployed target. */
78
86
  operator?: OperatorSignInOptions;
79
87
  actors: Record<string, ResolvedPersona>;
@@ -0,0 +1,38 @@
1
+ /**
2
+ * A `CHECK (col IN ('a','b',…))` constraint is an enum by another name, and
3
+ * both introspectors read one back as SQL text: SQLite stores the `CREATE
4
+ * TABLE` verbatim, Postgres hands back a normalised `pg_get_constraintdef`.
5
+ * The parsing they need differs, but the quoting rules do not, so both go
6
+ * through here.
7
+ */
8
+ /**
9
+ * Blank out `--` line comments and `/* … *\/` block comments, leaving a string
10
+ * of the same shape so offsets and line breaks still line up.
11
+ *
12
+ * Quoted text is skipped rather than scanned: `'a--b'` is a value, not a
13
+ * comment. The reverse — reading a comment as SQL — is what makes this
14
+ * necessary at all: an apostrophe in `-- the clinician's copy` opens a string
15
+ * that runs to the next quote in the real list, and a bracket in `-- (see
16
+ * below)` closes the list early. Either way the CHECK survives as a union that
17
+ * is wrong, which is worse than no union.
18
+ */
19
+ export declare function stripSqlComments(sql: string): string;
20
+ /** The single-quoted literals in `text`, with doubled quotes unescaped. */
21
+ export declare function quotedValues(text: string): string[];
22
+ /**
23
+ * Map each column constrained by a `CHECK (col IN (…))` in a `CREATE TABLE`
24
+ * statement to its allowed values. Only the positive `col IN (…)` form is
25
+ * recognised (the convention); `NOT IN`, ranges and boolean expressions are
26
+ * left for the column to stay a plain string.
27
+ */
28
+ export declare function parseCheckEnumsFromDdl(ddl: string): Map<string, string[]>;
29
+ /**
30
+ * The allowed values in a Postgres check constraint, or `undefined` when the
31
+ * constraint is not an enumeration.
32
+ *
33
+ * Postgres does not store the `IN (…)` you wrote — `pg_get_constraintdef`
34
+ * rewrites it as `= ANY (ARRAY[…])`, with each element cast to the column's
35
+ * type. Matching that shape is deliberate: `NOT IN` normalises to `<> ALL
36
+ * (ARRAY[…])` and a range to `>`/`<`, and neither is an enum.
37
+ */
38
+ export declare function parseCheckEnumValues(constraintDef: string): string[] | undefined;
@@ -0,0 +1,103 @@
1
+ /**
2
+ * A `CHECK (col IN ('a','b',…))` constraint is an enum by another name, and
3
+ * both introspectors read one back as SQL text: SQLite stores the `CREATE
4
+ * TABLE` verbatim, Postgres hands back a normalised `pg_get_constraintdef`.
5
+ * The parsing they need differs, but the quoting rules do not, so both go
6
+ * through here.
7
+ */
8
+ /**
9
+ * Blank out `--` line comments and `/* … *\/` block comments, leaving a string
10
+ * of the same shape so offsets and line breaks still line up.
11
+ *
12
+ * Quoted text is skipped rather than scanned: `'a--b'` is a value, not a
13
+ * comment. The reverse — reading a comment as SQL — is what makes this
14
+ * necessary at all: an apostrophe in `-- the clinician's copy` opens a string
15
+ * that runs to the next quote in the real list, and a bracket in `-- (see
16
+ * below)` closes the list early. Either way the CHECK survives as a union that
17
+ * is wrong, which is worse than no union.
18
+ */
19
+ export function stripSqlComments(sql) {
20
+ let out = '';
21
+ let i = 0;
22
+ while (i < sql.length) {
23
+ const c = sql[i];
24
+ if (c === "'" || c === '"') {
25
+ const end = closingQuote(sql, i);
26
+ out += sql.slice(i, end);
27
+ i = end;
28
+ continue;
29
+ }
30
+ if (c === '-' && sql[i + 1] === '-') {
31
+ while (i < sql.length && sql[i] !== '\n') {
32
+ out += ' ';
33
+ i++;
34
+ }
35
+ continue;
36
+ }
37
+ if (c === '/' && sql[i + 1] === '*') {
38
+ const close = sql.indexOf('*/', i + 2);
39
+ const end = close === -1 ? sql.length : close + 2;
40
+ for (let k = i; k < end; k++)
41
+ out += sql[k] === '\n' ? '\n' : ' ';
42
+ i = end;
43
+ continue;
44
+ }
45
+ out += c;
46
+ i++;
47
+ }
48
+ return out;
49
+ }
50
+ /** Index just past the quoted run starting at `start`, doubled quotes included. */
51
+ function closingQuote(sql, start) {
52
+ const quote = sql[start];
53
+ let i = start + 1;
54
+ while (i < sql.length) {
55
+ if (sql[i] === quote) {
56
+ if (sql[i + 1] === quote) {
57
+ i += 2;
58
+ continue;
59
+ }
60
+ return i + 1;
61
+ }
62
+ i++;
63
+ }
64
+ return sql.length;
65
+ }
66
+ /** The single-quoted literals in `text`, with doubled quotes unescaped. */
67
+ export function quotedValues(text) {
68
+ return [...text.matchAll(/'((?:[^']|'')*)'/g)].map((m) => m[1].replace(/''/g, "'"));
69
+ }
70
+ /**
71
+ * Map each column constrained by a `CHECK (col IN (…))` in a `CREATE TABLE`
72
+ * statement to its allowed values. Only the positive `col IN (…)` form is
73
+ * recognised (the convention); `NOT IN`, ranges and boolean expressions are
74
+ * left for the column to stay a plain string.
75
+ */
76
+ export function parseCheckEnumsFromDdl(ddl) {
77
+ const out = new Map();
78
+ const sql = stripSqlComments(ddl);
79
+ const checkIn = /CHECK\s*\(\s*"?(\w+)"?\s+IN\s*\(([^)]*)\)/gi;
80
+ let m;
81
+ while ((m = checkIn.exec(sql))) {
82
+ const values = quotedValues(m[2]);
83
+ if (values.length > 0)
84
+ out.set(m[1], values);
85
+ }
86
+ return out;
87
+ }
88
+ /**
89
+ * The allowed values in a Postgres check constraint, or `undefined` when the
90
+ * constraint is not an enumeration.
91
+ *
92
+ * Postgres does not store the `IN (…)` you wrote — `pg_get_constraintdef`
93
+ * rewrites it as `= ANY (ARRAY[…])`, with each element cast to the column's
94
+ * type. Matching that shape is deliberate: `NOT IN` normalises to `<> ALL
95
+ * (ARRAY[…])` and a range to `>`/`<`, and neither is an enum.
96
+ */
97
+ export function parseCheckEnumValues(constraintDef) {
98
+ const any = /=\s*ANY\s*\(\s*\(?\s*ARRAY\s*\[([^\]]*)\]/i.exec(constraintDef);
99
+ if (!any)
100
+ return undefined;
101
+ const values = quotedValues(any[1]);
102
+ return values.length > 0 ? values : undefined;
103
+ }
@@ -641,6 +641,21 @@ export async function generateSchemaTypes(introspector, options) {
641
641
  const coercionBody = [
642
642
  `// Generated by @pikku/cli — do not edit by hand.`,
643
643
  `// Run \`pikku db migrate\` to refresh.`,
644
+ `//`,
645
+ `// Nothing applies this for you. Attach it where the kysely singleton is`,
646
+ `// created, in createSingletonServices:`,
647
+ `//`,
648
+ `// import { createCoercionPlugin } from '@pikku/kysely'`,
649
+ `// import { coercionMap } from '#pikku/db/coercion.gen.js'`,
650
+ `//`,
651
+ `// kysely: existingServices.kysely.withPlugin(`,
652
+ `// createCoercionPlugin({ map: coercionMap }),`,
653
+ `// )`,
654
+ `//`,
655
+ `// Unwired, this file is invisible locally and fatal deployed: the dev`,
656
+ `// driver hydrates a TEXT date into a Date by itself, a deployed one`,
657
+ `// returns the raw string, and the generated schema types the column`,
658
+ `// \`Date\` either way — so tsc flags nothing and no local test can fail.`,
644
659
  ``,
645
660
  `export const coercionMap = {`,
646
661
  coercionEntries,
@@ -12,6 +12,20 @@ export declare class PostgresIntrospector implements DbIntrospector {
12
12
  connect(): Promise<void>;
13
13
  listTables(): Promise<string[]>;
14
14
  getColumns(table: string): Promise<ColumnInfo[]>;
15
+ /**
16
+ * Columns constrained to a value list by a `CHECK (col IN (…))`, keyed like
17
+ * {@link getAllColumns} then by column name. Postgres has native enums, but
18
+ * a CHECK is the other half of how a schema says "one of these" — and the
19
+ * one an app written against SQLite as well ends up using, since SQLite has
20
+ * no enums at all. Left as plain `string`, every caller narrows it by hand.
21
+ *
22
+ * Only single-column constraints are read: `conkey` naming two columns means
23
+ * neither is restricted to the list on its own.
24
+ *
25
+ * `table` limits the sweep to one table; omitted, it covers the schema. Both
26
+ * are one round-trip — see {@link getAllColumns}.
27
+ */
28
+ private getCheckEnums;
15
29
  getForeignKeys(table: string): Promise<ForeignKeyInfo[]>;
16
30
  getAllColumns(): Promise<Map<string, ColumnInfo[]>>;
17
31
  getAllForeignKeys(): Promise<Map<string, ForeignKeyInfo[]>>;
@@ -1,5 +1,6 @@
1
1
  import { Pool } from 'pg';
2
2
  import { MIGRATION_TRACKING_TABLE } from '../db-migrator.js';
3
+ import { parseCheckEnumValues } from '../check-enums.js';
3
4
  /** Table display name matching `listTables` (schema-qualified unless `public`). */
4
5
  function tableKey(schema, table) {
5
6
  return schema === 'public' ? table : `${schema}.${table}`;
@@ -85,6 +86,8 @@ export class PostgresIntrospector {
85
86
  FROM information_schema.columns c
86
87
  WHERE c.table_schema = $2 AND c.table_name = $1
87
88
  ORDER BY c.ordinal_position`, [tableName, schema]);
89
+ const checkEnums = (await this.getCheckEnums({ schema, name: tableName })).get(table) ??
90
+ new Map();
88
91
  return result.rows.map((r) => ({
89
92
  name: r.column_name,
90
93
  type: resolveColumnType(r.data_type, r.udt_name),
@@ -93,8 +96,51 @@ export class PostgresIntrospector {
93
96
  pk: Boolean(r.is_pk),
94
97
  defaultValue: r.column_default,
95
98
  generated: r.is_generated === 'ALWAYS',
99
+ enumValues: checkEnums.get(r.column_name),
96
100
  }));
97
101
  }
102
+ /**
103
+ * Columns constrained to a value list by a `CHECK (col IN (…))`, keyed like
104
+ * {@link getAllColumns} then by column name. Postgres has native enums, but
105
+ * a CHECK is the other half of how a schema says "one of these" — and the
106
+ * one an app written against SQLite as well ends up using, since SQLite has
107
+ * no enums at all. Left as plain `string`, every caller narrows it by hand.
108
+ *
109
+ * Only single-column constraints are read: `conkey` naming two columns means
110
+ * neither is restricted to the list on its own.
111
+ *
112
+ * `table` limits the sweep to one table; omitted, it covers the schema. Both
113
+ * are one round-trip — see {@link getAllColumns}.
114
+ */
115
+ async getCheckEnums(table) {
116
+ const result = await this.client.query(`SELECT n.nspname AS table_schema,
117
+ cl.relname AS table_name,
118
+ a.attname AS column_name,
119
+ pg_get_constraintdef(co.oid) AS def
120
+ FROM pg_constraint co
121
+ JOIN pg_class cl ON cl.oid = co.conrelid
122
+ JOIN pg_namespace n ON n.oid = cl.relnamespace
123
+ JOIN pg_attribute a ON a.attrelid = co.conrelid AND a.attnum = co.conkey[1]
124
+ WHERE co.contype = 'c'
125
+ AND array_length(co.conkey, 1) = 1
126
+ AND n.nspname NOT IN ('information_schema', 'pg_catalog', 'pg_toast')
127
+ AND n.nspname NOT LIKE 'pg_temp_%'
128
+ AND ($1::text IS NULL OR (n.nspname = $1 AND cl.relname = $2))`, [table?.schema ?? null, table?.name ?? null]);
129
+ const byTable = new Map();
130
+ for (const r of result.rows) {
131
+ const values = parseCheckEnumValues(r.def);
132
+ if (!values)
133
+ continue;
134
+ const key = tableKey(r.table_schema, r.table_name);
135
+ let cols = byTable.get(key);
136
+ if (!cols) {
137
+ cols = new Map();
138
+ byTable.set(key, cols);
139
+ }
140
+ cols.set(r.column_name, values);
141
+ }
142
+ return byTable;
143
+ }
98
144
  async getForeignKeys(table) {
99
145
  const dotIdx = table.indexOf('.');
100
146
  const schema = dotIdx >= 0 ? table.slice(0, dotIdx) : 'public';
@@ -153,6 +199,7 @@ export class PostgresIntrospector {
153
199
  WHERE c.table_schema NOT IN ('information_schema', 'pg_catalog', 'pg_toast')
154
200
  AND c.table_schema NOT LIKE 'pg_temp_%'
155
201
  ORDER BY c.table_schema, c.table_name, c.ordinal_position`);
202
+ const checkEnums = await this.getCheckEnums();
156
203
  const byTable = new Map();
157
204
  for (const r of result.rows) {
158
205
  if (isMigrationTracking(r.table_name))
@@ -171,6 +218,7 @@ export class PostgresIntrospector {
171
218
  pk: Boolean(r.is_pk),
172
219
  defaultValue: r.column_default,
173
220
  generated: r.is_generated === 'ALWAYS',
221
+ enumValues: checkEnums.get(key)?.get(r.column_name),
174
222
  });
175
223
  }
176
224
  return byTable;
@@ -7,10 +7,9 @@ export declare class SqliteIntrospector implements DbIntrospector {
7
7
  getColumns(table: string): Promise<ColumnInfo[]>;
8
8
  /**
9
9
  * SQLite has no native enums, but a `CHECK (col IN ('a','b',…))` constraint is
10
- * an enum by another name. Parse the table's stored `CREATE TABLE` DDL and map
11
- * each constrained column to its allowed values so codegen can type it as a
12
- * union. Only the positive `col IN (…)` form is recognised (the convention);
13
- * `NOT IN`, ranges, and boolean expressions are left as plain `string`.
10
+ * an enum by another name. Read back the table's stored `CREATE TABLE` DDL —
11
+ * which SQLite keeps verbatim, comments included — and map each constrained
12
+ * column to its allowed values so codegen can type it as a union.
14
13
  */
15
14
  private parseCheckEnums;
16
15
  getForeignKeys(table: string): Promise<ForeignKeyInfo[]>;
@@ -1,4 +1,5 @@
1
1
  import { MIGRATION_TRACKING_TABLE } from '../db-migrator.js';
2
+ import { parseCheckEnumsFromDdl } from '../check-enums.js';
2
3
  const SKIP_TABLES = new Set(['sqlite_sequence', MIGRATION_TRACKING_TABLE]);
3
4
  export class SqliteIntrospector {
4
5
  db;
@@ -34,28 +35,15 @@ export class SqliteIntrospector {
34
35
  }
35
36
  /**
36
37
  * SQLite has no native enums, but a `CHECK (col IN ('a','b',…))` constraint is
37
- * an enum by another name. Parse the table's stored `CREATE TABLE` DDL and map
38
- * each constrained column to its allowed values so codegen can type it as a
39
- * union. Only the positive `col IN (…)` form is recognised (the convention);
40
- * `NOT IN`, ranges, and boolean expressions are left as plain `string`.
38
+ * an enum by another name. Read back the table's stored `CREATE TABLE` DDL —
39
+ * which SQLite keeps verbatim, comments included — and map each constrained
40
+ * column to its allowed values so codegen can type it as a union.
41
41
  */
42
42
  parseCheckEnums(table) {
43
- const out = new Map();
44
43
  const row = this.db
45
44
  .prepare(`SELECT sql FROM sqlite_master WHERE type = 'table' AND name = ?`)
46
45
  .get(table);
47
- const ddl = row?.sql;
48
- if (!ddl)
49
- return out;
50
- const checkIn = /CHECK\s*\(\s*"?(\w+)"?\s+IN\s*\(([^)]*)\)/gi;
51
- let m;
52
- while ((m = checkIn.exec(ddl))) {
53
- const column = m[1];
54
- const values = [...m[2].matchAll(/'((?:[^']|'')*)'/g)].map((q) => q[1].replace(/''/g, "'"));
55
- if (values.length > 0)
56
- out.set(column, values);
57
- }
58
- return out;
46
+ return row?.sql ? parseCheckEnumsFromDdl(row.sql) : new Map();
59
47
  }
60
48
  async getForeignKeys(table) {
61
49
  const escaped = `"${table.replace(/"/g, '""')}"`;
@@ -12,12 +12,15 @@ export interface VirtualUserGenOutput {
12
12
  * `pikku persona run` does the same thing from a terminal; this is the same run
13
13
  * started over RPC, from CI or from a console, with the result kept.
14
14
  *
15
- * A virtual user is NOT a workflow and NOT a queued job. It explores, so no two
16
- * attempts take the same steps and there is nothing to replay; and the run
17
- * record already carries the progress a queue would only be holding on the way
18
- * here. So `runVirtualUser` writes the record, kicks the run off without
19
- * awaiting it, and returns the id. The one cost is stated on the record: a
20
- * restart mid-run strands it at `running`.
15
+ * A virtual user is still NOT a workflow: it explores, so no two attempts take
16
+ * the same steps and there is nothing to replay. What it does need is a
17
+ * trigger. `runVirtualUser` writes the record and returns the id; the run
18
+ * itself is dispatched onto `pikku-virtual-user-runs`, at one attempt, because
19
+ * a queue is the only dispatch that survives a deployment which puts each
20
+ * function in its own unit — there is no in-process promise to leave running
21
+ * there, and an RPC to a function nothing triggers has nowhere to land. A
22
+ * project with no queue service keeps the in-process dispatch, which is
23
+ * correct for the single process it runs in.
21
24
  *
22
25
  * Emitted as two files. The schemas are zod, and the inspector reads a zod
23
26
  * schema by importing the module that declares it — which it cannot do for the
@@ -8,12 +8,15 @@
8
8
  * `pikku persona run` does the same thing from a terminal; this is the same run
9
9
  * started over RPC, from CI or from a console, with the result kept.
10
10
  *
11
- * A virtual user is NOT a workflow and NOT a queued job. It explores, so no two
12
- * attempts take the same steps and there is nothing to replay; and the run
13
- * record already carries the progress a queue would only be holding on the way
14
- * here. So `runVirtualUser` writes the record, kicks the run off without
15
- * awaiting it, and returns the id. The one cost is stated on the record: a
16
- * restart mid-run strands it at `running`.
11
+ * A virtual user is still NOT a workflow: it explores, so no two attempts take
12
+ * the same steps and there is nothing to replay. What it does need is a
13
+ * trigger. `runVirtualUser` writes the record and returns the id; the run
14
+ * itself is dispatched onto `pikku-virtual-user-runs`, at one attempt, because
15
+ * a queue is the only dispatch that survives a deployment which puts each
16
+ * function in its own unit — there is no in-process promise to leave running
17
+ * there, and an RPC to a function nothing triggers has nowhere to land. A
18
+ * project with no queue service keeps the in-process dispatch, which is
19
+ * correct for the single process it runs in.
17
20
  *
18
21
  * Emitted as two files. The schemas are zod, and the inspector reads a zod
19
22
  * schema by importing the module that declares it — which it cannot do for the
@@ -265,6 +268,7 @@ export const ExecuteVirtualUserRunOutput = z.object({
265
268
  */
266
269
  import { pikkuFunc, pikkuSessionlessFunc, type Session } from '${leaf('function')}'
267
270
  import { pikkuMiddleware } from '${leaf('middleware')}'
271
+ import { wireQueueWorker } from '${leaf('queue')}'
268
272
  import { defineScope } from '${leaf('scopes')}'
269
273
  import {
270
274
  executeVirtualUserRun as executeRun,
@@ -326,7 +330,11 @@ export const runVirtualUser = pikkuFunc({
326
330
  scopes: ['virtualUser:run'],
327
331
  input: RunVirtualUserInput,
328
332
  output: RunVirtualUserOutput,
329
- func: async ({ virtualUserRunStore, config }, input, { session, rpc }) => {
333
+ func: async (
334
+ { virtualUserRunStore, config, queueService },
335
+ input,
336
+ { session, rpc }
337
+ ) => {
330
338
  const { runId, persona, disposition, goals, memory, seed } =
331
339
  await startVirtualUserRun({
332
340
  store: virtualUserRunStore,
@@ -345,26 +353,35 @@ export const runVirtualUser = pikkuFunc({
345
353
  startedBy: session?.userId ?? null,
346
354
  })
347
355
 
348
- // Deliberately not awaited: a run takes minutes, and a held-open request
349
- // survives neither a rollout nor a proxy timeout. executeVirtualUserRun
350
- // writes both outcomes to the record itself, so the only thing left to
351
- // handle here is a rejection escaping the promise — without the catch it
352
- // becomes an unhandled rejection and takes the process with it.
353
- void rpc!
354
- .invoke('executeVirtualUserRun', {
355
- runId,
356
- persona,
357
- disposition,
358
- goals,
359
- memory,
360
- seed,
361
- budget: input.budget,
362
- // Rides the dispatch and nothing else. The run record is deliberately
363
- // not given it: a record outlives the run, and a credential in a row
364
- // somebody can read back is a credential leak.
365
- operatorToken: input.operatorToken,
356
+ const job = {
357
+ runId,
358
+ persona,
359
+ disposition,
360
+ goals,
361
+ memory,
362
+ seed,
363
+ budget: input.budget,
364
+ // Rides the dispatch and nothing else. The run record is deliberately
365
+ // not given it: a record outlives the run, and a credential in a row
366
+ // somebody can read back is a credential leak.
367
+ operatorToken: input.operatorToken,
368
+ }
369
+
370
+ if (queueService) {
371
+ // One attempt. A run is an exploration, so a redelivery is a second
372
+ // different outing writing into a record that already has an outcome.
373
+ await queueService.add('pikku-virtual-user-runs', job, {
374
+ attempts: 1,
375
+ pikkuUserId: session?.userId,
366
376
  })
367
- .catch(() => {})
377
+ } else {
378
+ // Deliberately not awaited: a run takes minutes, and a held-open request
379
+ // survives neither a rollout nor a proxy timeout. executeVirtualUserRun
380
+ // writes both outcomes to the record itself, so the only thing left to
381
+ // handle here is a rejection escaping the promise — without the catch it
382
+ // becomes an unhandled rejection and takes the process with it.
383
+ void rpc!.invoke('executeVirtualUserRun', job).catch(() => {})
384
+ }
368
385
 
369
386
  return { runId }
370
387
  },
@@ -560,7 +577,8 @@ export const tickVirtualUserSchedules = pikkuSessionlessFunc<void, void>({
560
577
 
561
578
  /**
562
579
  * The run itself. Not exposed: it is dispatched by \`runVirtualUser\` and has no
563
- * caller of its own.
580
+ * caller of its own — but it is wired to a queue below, which is what gives it
581
+ * a unit of its own under a per-function deploy.
564
582
  */
565
583
  export const executeVirtualUserRun = pikkuSessionlessFunc({
566
584
  tags: ['pikku'],
@@ -581,6 +599,12 @@ export const executeVirtualUserRun = pikkuSessionlessFunc({
581
599
  ...input,
582
600
  }),
583
601
  })
602
+
603
+ wireQueueWorker({
604
+ name: 'pikku-virtual-user-runs',
605
+ tags: ['pikku'],
606
+ func: executeVirtualUserRun,
607
+ })
584
608
  `;
585
609
  return { schemas, functions };
586
610
  };