@pikku/skills 0.12.2 → 0.12.6

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 (68) hide show
  1. package/dist/skills.gen.js +1 -1
  2. package/package.json +1 -1
  3. package/skills/pikku-addon/SKILL.md +56 -29
  4. package/skills/pikku-ai-agent/SKILL.md +197 -105
  5. package/skills/pikku-ai-vercel/SKILL.md +57 -18
  6. package/skills/pikku-ai-voice/SKILL.md +126 -52
  7. package/skills/pikku-audit/SKILL.md +35 -13
  8. package/skills/pikku-aws/SKILL.md +66 -16
  9. package/skills/pikku-backblaze/SKILL.md +44 -11
  10. package/skills/pikku-better-auth/SKILL.md +80 -34
  11. package/skills/pikku-cli/SKILL.md +67 -18
  12. package/skills/pikku-cli/references/complete-example.md +2 -0
  13. package/skills/pikku-concepts/SKILL.md +82 -10
  14. package/skills/pikku-concepts/references/concept-mapping.md +2 -2
  15. package/skills/pikku-config/SKILL.md +134 -52
  16. package/skills/pikku-cron/SKILL.md +13 -6
  17. package/skills/pikku-deploy-azure/SKILL.md +83 -28
  18. package/skills/pikku-deploy-cloudflare/SKILL.md +79 -37
  19. package/skills/pikku-deploy-express/SKILL.md +40 -4
  20. package/skills/pikku-deploy-fastify/SKILL.md +22 -1
  21. package/skills/pikku-deploy-lambda/SKILL.md +99 -19
  22. package/skills/pikku-deploy-nextjs/SKILL.md +49 -5
  23. package/skills/pikku-deploy-uws/SKILL.md +54 -1
  24. package/skills/pikku-deps/SKILL.md +29 -8
  25. package/skills/pikku-emails/SKILL.md +36 -5
  26. package/skills/pikku-fabric/SKILL.md +30 -5
  27. package/skills/pikku-fabric-debug/SKILL.md +5 -1
  28. package/skills/pikku-feature/SKILL.md +12 -7
  29. package/skills/pikku-gateway-slack/SKILL.md +72 -11
  30. package/skills/pikku-http/SKILL.md +18 -5
  31. package/skills/pikku-http/references/http-options.md +10 -5
  32. package/skills/pikku-i18n/SKILL.md +18 -7
  33. package/skills/pikku-info/SKILL.md +18 -8
  34. package/skills/pikku-jose/SKILL.md +35 -6
  35. package/skills/pikku-knowledge/SKILL.md +3 -3
  36. package/skills/pikku-kysely/SKILL.md +78 -15
  37. package/skills/pikku-machine-auth/SKILL.md +36 -1
  38. package/skills/pikku-mcp/SKILL.md +159 -149
  39. package/skills/pikku-middleware/SKILL.md +17 -5
  40. package/skills/pikku-mongodb/SKILL.md +10 -2
  41. package/skills/pikku-n8n-import/SKILL.md +14 -6
  42. package/skills/pikku-permissions/SKILL.md +102 -22
  43. package/skills/pikku-pino/SKILL.md +12 -4
  44. package/skills/pikku-product-second-opinion/SKILL.md +3 -3
  45. package/skills/pikku-queue/SKILL.md +45 -16
  46. package/skills/pikku-react/SKILL.md +41 -14
  47. package/skills/pikku-react-query/SKILL.md +14 -10
  48. package/skills/pikku-realtime/SKILL.md +44 -22
  49. package/skills/pikku-redis/SKILL.md +12 -3
  50. package/skills/pikku-rpc/SKILL.md +23 -12
  51. package/skills/pikku-rtl/SKILL.md +21 -17
  52. package/skills/pikku-scenario/SKILL.md +285 -50
  53. package/skills/pikku-schedule/SKILL.md +39 -6
  54. package/skills/pikku-schema-ajv/SKILL.md +24 -2
  55. package/skills/pikku-schema-cfworker/SKILL.md +22 -2
  56. package/skills/pikku-security/SKILL.md +54 -9
  57. package/skills/pikku-services/SKILL.md +49 -9
  58. package/skills/pikku-services/references/audit-wire-service.md +2 -1
  59. package/skills/pikku-software-archaeology/README.md +16 -6
  60. package/skills/pikku-software-archaeology/references/pikku-mapping.md +30 -30
  61. package/skills/pikku-template-clone/SKILL.md +10 -5
  62. package/skills/pikku-trigger/SKILL.md +50 -6
  63. package/skills/pikku-versioning/SKILL.md +46 -17
  64. package/skills/pikku-websocket/SKILL.md +72 -44
  65. package/skills/pikku-workflow/SKILL.md +35 -1
  66. package/skills/pikku-workflow/references/workflow-reference.md +13 -8
  67. package/skills/pikku-workflows-client/SKILL.md +13 -6
  68. package/skills/pikku-ws/SKILL.md +44 -8
@@ -36,22 +36,55 @@ yarn add @pikku/schedule
36
36
  ```typescript
37
37
  import { InMemorySchedulerService } from '@pikku/schedule'
38
38
 
39
- const scheduler = new InMemorySchedulerService()
39
+ const schedulerService = new InMemorySchedulerService()
40
+ await schedulerService.start() // registers a CronJob per wired scheduled task
40
41
  ```
41
42
 
42
- Implements the scheduler service interface. Schedules are held in memory — they do not survive process restarts. Suitable for development and single-instance deployments.
43
+ It implements core's `SchedulerService` on two mechanisms: `cron` for the
44
+ recurring tasks you declared with `wireScheduler` (see `pikku-cron`), and
45
+ `setTimeout` for one-off delayed RPCs. Both live in process memory, so nothing
46
+ survives a restart and nothing is shared between instances — fine for
47
+ development and a single-instance deployment, wrong for anything else.
48
+
49
+ `PikkuTaskScheduler` is a deprecated alias for the same class.
50
+
51
+ ### Scheduling a one-off RPC
52
+
53
+ ```typescript
54
+ const taskId = await schedulerService.scheduleRPC('5m', 'sendReminder', data, session)
55
+ await schedulerService.getTask(taskId) // { rpcName, scheduledFor, status, … } | null
56
+ await schedulerService.getAllTasks() // pending one-offs only
57
+ await schedulerService.unschedule(taskId) // true when it was still pending
58
+ ```
59
+
60
+ The delay is milliseconds or a duration string (`'30s'`, `'5m'`, `'2h'`). This is
61
+ also the mechanism a workflow's delayed steps use, which is why a workflow that
62
+ sleeps needs a `schedulerService` registered.
43
63
 
44
64
  ## Usage Patterns
45
65
 
46
66
  ### Basic Setup
47
67
 
68
+ The scheduler is a singleton service under the name **`schedulerService`**, and
69
+ it is started in your server bootstrap — declaring it without calling `start()`
70
+ registers no cron jobs, so nothing ever fires:
71
+
48
72
  ```typescript
73
+ // start.ts
49
74
  import { InMemorySchedulerService } from '@pikku/schedule'
50
75
 
51
- const createSingletonServices = pikkuServices(async (config) => {
52
- const scheduler = new InMemorySchedulerService()
53
- return { config, scheduler }
76
+ const schedulerService = new InMemorySchedulerService()
77
+ const singletonServices = await createSingletonServices(config, {
78
+ schedulerService,
54
79
  })
80
+
81
+ await appServer.start()
82
+ await schedulerService.start()
55
83
  ```
56
84
 
57
- For distributed or persistent scheduling, use BullMQ (`BullSchedulerService`) or PgBoss (`PgBossSchedulerService`) from the queue packages instead. See `pikku-queue` for details.
85
+ Call `close()` on shutdown — it stops every cron job and clears pending timers.
86
+
87
+ For distributed or persistent scheduling, take the scheduler service off the
88
+ queue factory instead (`bullFactory.getSchedulerService()`,
89
+ `pgBossFactory.getSchedulerService()`) and register it under the same name. See
90
+ `pikku-queue`.
@@ -40,10 +40,32 @@ const schema = new AjvSchemaService(logger: Logger)
40
40
 
41
41
  **Methods:**
42
42
 
43
- - `compileSchema(schema: string, value: any): void` — Compile and register a JSON schema
43
+ - `compileSchema(name: string, schema: any): void` — Compile and register a JSON schema under `name`
44
44
  - `validateSchema(schemaName: string, json: any): void` — Validate data against a compiled schema (throws on failure)
45
45
  - `getSchemaNames(): Set<string>` — Get all registered schema names
46
- - `getSchemaKeys(schemaName: string): string[]` — Get property keys for a schema
46
+ - `getSchemaKeys(schemaName: string): string[]` — Top-level property keys, or `[]` if the schema has no `properties`
47
+
48
+ The first argument is the **name**, the second the schema — the parameter is
49
+ called `schema` in the source, which reads backwards.
50
+
51
+ ### Behaviour that matters
52
+
53
+ - **Registration is name-keyed and never re-compiles.** A second
54
+ `compileSchema('X', …)` with a different schema is a no-op; the first one wins
55
+ for the process lifetime. `@pikku/schema-cfworker` *does* recompile on a
56
+ changed value, so a dev hot-reload after codegen picks up a changed schema
57
+ there but not here — restart the process instead.
58
+ - **AJV is a module-level singleton**, shared by every `AjvSchemaService` you
59
+ construct, so compiled schema names are global to the process.
60
+ - **`useDefaults: true` mutates the validated object**, filling in schema
61
+ defaults in place. `coerceTypes: false`, so a query-string `"1"` will not
62
+ become `1` — the wiring layer is what coerces, not this service.
63
+ - `ajv-formats` is registered, so `format` keywords (`email`, `uuid`, `date-time`)
64
+ are enforced.
65
+ - A failed validation throws `UnprocessableContentError` (a 422). A *missing*
66
+ schema throws a bare string, `Missing validator for <name>` — not an `Error`,
67
+ so `catch (e) { e.message }` reads `undefined`. That normally means codegen
68
+ didn't run.
47
69
 
48
70
  ## Usage Patterns
49
71
 
@@ -41,10 +41,30 @@ const schema = new CFWorkerSchemaService(logger: Logger)
41
41
 
42
42
  **Methods:**
43
43
 
44
- - `compileSchema(schema: string, value: any): void` — Compile and register a JSON schema
44
+ - `compileSchema(name: string, schema: any): void` — Compile and register a JSON schema under `name`
45
45
  - `validateSchema(schemaName: string, json: any): void` — Validate data against a compiled schema (throws on failure)
46
46
  - `getSchemaNames(): Set<string>` — Get all registered schema names
47
- - `getSchemaKeys(schemaName: string): string[]` — Get property keys for a schema
47
+ - `getSchemaKeys(schemaName: string): string[]` — Top-level property keys, or `[]` if the schema has no `properties`
48
+
49
+ ### Where it differs from AJV
50
+
51
+ These two are not drop-in equivalents, and the differences are the kind that
52
+ surface as behaviour changes rather than compile errors:
53
+
54
+ - **No `useDefaults`.** AJV fills schema defaults into the validated object in
55
+ place; this validator does not. A field you relied on being defaulted arrives
56
+ `undefined` on Workers.
57
+ - **It re-compiles when the schema value changes.** AJV caches by name forever;
58
+ here a `compileSchema` with a different value for the same name replaces the
59
+ validator, which is what lets a dev hot-reload pick up regenerated schemas.
60
+ - **Each validator gets a deep clone of the schema** (`@cfworker/json-schema`
61
+ mutates what it is given, which throws on a frozen generated object).
62
+ - A compile failure throws `Error('Failed to compile schema: <name>')` with the
63
+ underlying cause swallowed — check the schema by hand when you see it.
64
+
65
+ A failed validation throws `UnprocessableContentError` (422) with the validator
66
+ errors joined; a *missing* schema throws a bare string, `Missing validator for
67
+ <name>`, not an `Error`.
48
68
 
49
69
  ## Usage Patterns
50
70
 
@@ -23,6 +23,10 @@ For **permissions** (pikkuPermission, pikkuAuth, per-function authorization) see
23
23
 
24
24
  ## Session Management
25
25
 
26
+ `session`, `setSession` and `clearSession` live on the **wire** — the function's
27
+ third argument — not on services. `setSession`/`clearSession` may be async
28
+ (cookie and session-store backends write on the way out), so await them.
29
+
26
30
  ```typescript
27
31
  // Read session in pikkuFunc (session guaranteed to exist)
28
32
  const getProfile = pikkuFunc({
@@ -36,7 +40,7 @@ const login = pikkuFunc({
36
40
  auth: false,
37
41
  func: async ({ jwt, db }, { email, password }, { setSession }) => {
38
42
  const user = await db.verifyCredentials(email, password)
39
- setSession({ userId: user.id })
43
+ await setSession({ userId: user.id })
40
44
  return { token: jwt.sign({ userId: user.id }) }
41
45
  },
42
46
  })
@@ -44,28 +48,31 @@ const login = pikkuFunc({
44
48
  // Clear session (logout)
45
49
  const logout = pikkuFunc({
46
50
  func: async ({}, _data, { clearSession }) => {
47
- clearSession()
51
+ await clearSession()
48
52
  },
49
53
  })
50
54
  ```
51
55
 
56
+ `login` is `auth: false` because the caller has no session yet — a `pikkuFunc`
57
+ with the default `auth` would be rejected before its body ever ran.
58
+
52
59
  ## Built-in Auth Strategies
53
60
 
54
61
  Apply these via `addHTTPMiddleware` in a wirings file:
55
62
 
56
63
  ```typescript
57
64
  import { authBearer, authCookie, authAPIKey } from '@pikku/core/middleware'
58
- import { addHTTPMiddleware } from '@pikku/core/http'
65
+ import { addHTTPMiddleware } from '#pikku'
59
66
 
60
67
  // JWT bearer token — reads Authorization header
61
68
  addHTTPMiddleware('*', [authBearer()])
62
69
 
63
- // Cookie-based sessions — auto-refreshes JWT
70
+ // Cookie-based sessions — re-issues the cookie when the session changes
64
71
  addHTTPMiddleware('*', [
65
72
  authCookie({
66
73
  name: 'session',
67
74
  expiresIn: { value: 30, unit: 'day' },
68
- options: { httpOnly: true, secure: true },
75
+ options: { sameSite: 'strict' },
69
76
  }),
70
77
  ])
71
78
 
@@ -73,6 +80,40 @@ addHTTPMiddleware('*', [
73
80
  addHTTPMiddleware('*', [authAPIKey({ source: 'all' })])
74
81
  ```
75
82
 
83
+ All three share the same escape hatch: they do nothing when there is no HTTP
84
+ request, or when a session is already set. That is what lets you stack several —
85
+ whichever runs first and finds a credential wins, and the rest step aside — and
86
+ it is also why none of them authenticate a queue job, a scheduled task or a
87
+ channel message. Those need a session set another way.
88
+
89
+ Each decodes its credential with the `jwt` service; without one registered, they
90
+ silently authenticate nobody.
91
+
92
+ **`authBearer` in static-token mode.** Passing `token` switches it from decoding
93
+ a JWT to comparing (in constant time) against a fixed value — the shape to use
94
+ for a service-to-service caller or a demo:
95
+
96
+ ```typescript
97
+ authBearer({
98
+ token: {
99
+ secretId: 'AGENT_DEMO_TOKEN', // or: value: 'literal-token'
100
+ userSession: { userId: 'demo-user' },
101
+ },
102
+ })
103
+ ```
104
+
105
+ An unset secret leaves the middleware inert rather than erroring, so a template
106
+ that ships this is safe until someone provides the secret. A malformed
107
+ `Authorization` header (no `Bearer ` scheme) throws `InvalidSessionError` in
108
+ either mode.
109
+
110
+ **`authCookie` options.** `name`, `expiresIn` and `options` are all part of the
111
+ config; `options` merges over the defaults `{ httpOnly: true, secure: true,
112
+ sameSite: 'lax', path: '/' }`, so only override what you need. The cookie is
113
+ re-issued after the request only when the session actually changed, which is how
114
+ a rolling session extends itself without writing a `Set-Cookie` on every
115
+ response.
116
+
76
117
  ## Complete Example
77
118
 
78
119
  ```typescript
@@ -84,10 +125,14 @@ export const isVerified = pikkuAuth(async (_services, session) => !!session?.ema
84
125
 
85
126
  // wirings/auth.wiring.ts
86
127
  import { authCookie } from '@pikku/core/middleware'
87
- import { addHTTPMiddleware } from '@pikku/core/http'
128
+ import { addHTTPMiddleware } from '#pikku'
88
129
 
89
130
  addHTTPMiddleware('*', [
90
- authCookie({ name: 'session', expiresIn: { value: 30, unit: 'day' } }),
131
+ authCookie({
132
+ name: 'session',
133
+ expiresIn: { value: 30, unit: 'day' },
134
+ options: {},
135
+ }),
91
136
  ])
92
137
 
93
138
  // functions/auth.functions.ts
@@ -95,14 +140,14 @@ export const login = pikkuFunc({
95
140
  auth: false,
96
141
  func: async ({ jwt, db }, { email, password }, { setSession }) => {
97
142
  const user = await db.verifyCredentials(email, password)
98
- setSession({ userId: user.id })
143
+ await setSession({ userId: user.id })
99
144
  return { token: jwt.sign({ userId: user.id }) }
100
145
  },
101
146
  })
102
147
 
103
148
  export const logout = pikkuFunc({
104
149
  func: async ({}, _data, { clearSession }) => {
105
- clearSession()
150
+ await clearSession()
106
151
  },
107
152
  })
108
153
  ```
@@ -2,11 +2,13 @@
2
2
  name: pikku-services
3
3
  description: >-
4
4
  Use when setting up dependency injection, creating custom services, or configuring the service
5
- layer in a Pikku app. Covers pikkuServices (singleton), pikkuWireServices (per-request), service
6
- typing, built-in services, and tree-shaking. TRIGGER when: code uses
7
- pikkuServices/pikkuWireServices, user asks about services.ts, dependency injection, service
8
- factories, or built-in services (ConsoleLogger, JoseJWTService). DO NOT TRIGGER when: user asks
9
- about auth middleware (use pikku-security) or secrets/variables (use pikku-config).
5
+ layer in a Pikku app. Covers pikkuServices (singleton), pikkuWireServices (per-request),
6
+ pikkuServerLifecycle (startup/shutdown hooks), service typing, built-in services, and
7
+ tree-shaking. TRIGGER when: code uses pikkuServices/pikkuWireServices/pikkuServerLifecycle, user
8
+ asks about services.ts, lifecycle.ts, dependency injection, service factories, startup or
9
+ shutdown work, or built-in services (ConsoleLogger, JoseJWTService). DO NOT TRIGGER when: user asks
10
+ about middleware (use pikku-middleware), auth strategies or sessions (use pikku-security),
11
+ permissions (use pikku-permissions), or secrets/variables (use pikku-config).
10
12
  installGroups: [core]
11
13
  ---
12
14
 
@@ -74,6 +76,39 @@ export const createWireServices = pikkuWireServices(
74
76
  )
75
77
  ```
76
78
 
79
+ ### `pikkuServerLifecycle(hooks)` — startup and shutdown work
80
+
81
+ A service factory should **construct** services, not run startup side effects. Seeding a database, warming a cache, starting a background consumer or draining a queue belongs in lifecycle hooks, which receive the singleton services after they are built:
82
+
83
+ ```typescript
84
+ // src/lifecycle.ts
85
+ import { pikkuServerLifecycle } from '@pikku/core'
86
+ import type { SingletonServices } from '../types/application-types.js'
87
+
88
+ export const lifecycle = pikkuServerLifecycle<SingletonServices>({
89
+ beforeStart: async ({ kysely }) => {
90
+ await runMigrations(kysely) // before the port opens
91
+ },
92
+ afterStart: async (services) => {
93
+ await seedDevData(services) // server is accepting traffic
94
+ },
95
+ beforeStop: async ({ queueService }) => {
96
+ await queueService.drain() // services are still alive here
97
+ },
98
+ afterStop: async () => {
99
+ await releaseExternalLock() // services are ALREADY stopped
100
+ },
101
+ })
102
+ ```
103
+
104
+ Every hook is optional. Order is `beforeStart` → server starts → `afterStart`, then on SIGINT `beforeStop` → services stopped → server stopped → `afterStop`.
105
+
106
+ **`afterStop` runs after the singleton services have been stopped.** It still receives the services object, but the services inside it are shut down — using one there is a use-after-close bug. Anything that needs a live service goes in `beforeStop`.
107
+
108
+ Export **exactly one** `pikkuServerLifecycle` from anywhere in `srcDirectories`; the inspector finds it by the wrapper call, so the filename is free (`src/lifecycle.ts` by convention). It must be an exported `const` initialized with a direct call to `pikkuServerLifecycle` — a re-export or a conditional wrapper is invisible to the inspector.
109
+
110
+ **Only `pikku dev` and `pikku serve` run these hooks.** If you bootstrap your own server (Express, Fastify, uWS, Lambda, Cloudflare, Next.js), no runtime adapter invokes them — put the work in your entrypoint instead.
111
+
77
112
  ### Auto-Generated Service Manifest
78
113
 
79
114
  After `npx pikku all`, Pikku generates `.pikku/pikku-services.gen.ts`, a manifest of which services are actually used by wired functions:
@@ -97,7 +132,7 @@ export type RequiredSingletonServices = Pick<
97
132
 
98
133
  ### Using Services in Functions
99
134
 
100
- **Every service must be declared in `SingletonServices` (or `Services`) in `application-types.d.ts`.** Never access a service via a body-level cast (`services as typeof services & { myService: MyService }`) — that means the type is missing. Add the import and the field to `SingletonServices`, then destructure inline in the function signature. The inspector emits `SERVICES_NOT_DESTRUCTURED` and tree-shaking breaks when the first param is a plain identifier rather than an object pattern. Never `new` a service inside a function — services arrive only via injection.
135
+ **Every service must be declared in `SingletonServices` (or `Services`) in `application-types.d.ts`.** Never access a service via a body-level cast (`services as typeof services & { myService: MyService }`) — that means the type is missing. Add the import and the field to `SingletonServices`, then destructure inline in the function signature. The inspector emits `SERVICES_NOT_DESTRUCTURED` (`PKU410`) and tree-shaking breaks when the first param is a plain identifier rather than an object pattern. Never `new` a service inside a function — services arrive only via injection.
101
136
 
102
137
  ```typescript
103
138
  // ✅ Correct — inline destructure, no cast
@@ -122,15 +157,20 @@ const getUser = pikkuFunc({
122
157
 
123
158
  **Never write a `if (!service) throw ...` existence guard in a function body.** It is dead code, and it defeats the platform.
124
159
 
125
- Optionality lives in exactly one place — `services.ts` / the `SingletonServices` declaration — and it means *"this may not be created"*, not *"this may be missing at call time"*. A service is optional precisely because **nothing destructures it**, and `requireSingletonServices` therefore never creates it. The moment any wired function destructures it, Pikku creates it and guarantees it is there.
160
+ Optionality lives in exactly one place — `services.ts` / the `SingletonServices` declaration — and it means *"this may not be created"*, not *"this may be missing at call time"*. A service is optional precisely because **nothing destructures it**, and the generated `requiredSingletonServices` manifest therefore never marks it for creation. The moment any wired function destructures it, Pikku creates it and guarantees it is there.
126
161
 
127
162
  The types enforce this rather than merely documenting it. The inspector records the services destructured by every wired `func`, `permissions` **and** `middleware`, and emits them as `RequiredSingletonServices`. The generated function types then default their service parameter to:
128
163
 
129
164
  ```typescript
130
165
  export type WiredSingletonServices = RequiredSingletonServices & SingletonServices
131
- export type WiredServices = RequiredSingletonServices & Services
166
+ export type WiredServices = SecretlessServices<RequiredSingletonServices & Services>
132
167
  ```
133
168
 
169
+ The `SecretlessServices<...>` wrapper is why `secrets` never appears in a
170
+ function's services: it is stripped at the type level, not merely omitted by
171
+ convention. Read secrets in a service factory or middleware and hand the value
172
+ to a service instead.
173
+
134
174
  so a service that is `foo?: Foo` in `SingletonServices` arrives as a non-optional `Foo` in every function, permission and middleware that uses it. There is nothing to guard against.
135
175
 
136
176
  ```typescript
@@ -232,7 +272,7 @@ export const createSingletonServices = pikkuServices(async (config) => {
232
272
 
233
273
  export const createWireServices = pikkuWireServices(
234
274
  async (singletonServices, wire) => ({
235
- scopedLogger: new ScopedLogger(wire.session?.initial?.userId),
275
+ scopedLogger: new ScopedLogger(wire.session?.userId),
236
276
  })
237
277
  )
238
278
 
@@ -23,7 +23,8 @@ The `audit` wire service is typed as `AuditLog` (from `@pikku/core`). Functions
23
23
  ```typescript
24
24
  const deleteUser = pikkuFunc({
25
25
  func: async ({ audit }, { userId }) => {
26
- await audit.audit({ type: 'user.deleted', actor_user_id: userId })
26
+ // The user identity comes from the wire session — the payload is metadata.
27
+ await audit.write({ type: 'user.deleted', source: 'explicit', metadata: { userId } })
27
28
  // ...
28
29
  },
29
30
  })
@@ -6,7 +6,7 @@ Reverse-engineers an existing repository into a **Product Blueprint**: the produ
6
6
  Existing Repository → pikku-software-archaeology → .knowledge/ blueprint → new Pikku application
7
7
  ```
8
8
 
9
- This is **not** a code indexer or doc generator. It extracts *intent over implementation*: `POST /api/users/:id/status` becomes the command `ActivateUser`; three scattered `if (inv.user_id !== req.user.id)` checks become one `InvoiceOwnerOnly` policy with three `enforcedAt` citations.
9
+ This is **not** a code indexer or doc generator. It extracts _intent over implementation_: `POST /api/users/:id/status` becomes the command `ActivateUser`; three scattered `if (inv.user_id !== req.user.id)` checks become one `InvoiceOwnerOnly` policy with three `enforcedAt` citations.
10
10
 
11
11
  ## Design decision: the AI is the parser
12
12
 
@@ -19,8 +19,9 @@ In Claude Code, from (or pointing at) the target repo:
19
19
  > Use the pikku-software-archaeology skill to extract a product blueprint from /path/to/repo
20
20
 
21
21
  The agent then:
22
+
22
23
  1. **Surveys** the repo (manifests, entry points, routes, jobs, webhooks, schema, config, TODO/HACK markers) — facts only.
23
- 2. **Excavates the test suite** — `describe`/`it` names become workflow scenarios; assertions confirm policies and upgrade confidence; rules that exist *only* in tests are captured.
24
+ 2. **Excavates the test suite** — `describe`/`it` names become workflow scenarios; assertions confirm policies and upgrade confidence; rules that exist _only_ in tests are captured.
24
25
  3. **Extracts** through twelve lenses (domains, entities, commands, …) per the pipeline in `SKILL.md`. Large repos fan out subagents per lens and merge.
25
26
  4. **Cross-checks and validates**:
26
27
  ```bash
@@ -37,15 +38,23 @@ Concept names are the stable IDs. On re-run after code changes, re-extract only
37
38
 
38
39
  ## How Pikku consumes the blueprint
39
40
 
40
- Full mapping table in `references/pikku-mapping.md`. Summary: entities → Kysely migrations + Zod schemas; commands/queries → `pikkuFunc`s; api surfaces → `wireHTTP`; policies → shared permission functions (collapsing duplicated legacy checks); system workflows → `wireScheduler`/`wireQueueWorker`/`pikkuWorkflowFunc`; integrations → injected services with `wireSecret`/`wireCredential`; test-derived scenarios → `pikkuUserFlow` stories / e2e tests. Humans resolve `migration.json.decisionsNeeded` before any generation starts.
41
+ Full mapping table in `references/pikku-mapping.md`. Summary: entities → Kysely migrations + Zod schemas; commands/queries → `pikkuFunc`s; api surfaces → `wireHTTP`; policies → shared permission functions (collapsing duplicated legacy checks); system workflows → `wireScheduler`/`wireQueueWorker`/`pikkuWorkflowFunc`; integrations → injected services with `defineSecret`/`defineCredential`; test-derived scenarios → `pikkuUserFlow` stories / e2e tests. Humans resolve `migration.json.decisionsNeeded` before any generation starts.
41
42
 
42
43
  ## How uncertainty is represented
43
44
 
44
45
  Every extracted concept carries:
45
46
 
46
47
  ```json
47
- { "evidence": [{ "file": "controllers/invoices.js", "lines": "52", "note": "guard: only drafts editable" }],
48
- "confidence": "high" }
48
+ {
49
+ "evidence": [
50
+ {
51
+ "file": "controllers/invoices.js",
52
+ "lines": "52",
53
+ "note": "guard: only drafts editable"
54
+ }
55
+ ],
56
+ "confidence": "high"
57
+ }
49
58
  ```
50
59
 
51
60
  - **high** — the behavior itself is in the cited code/schema/test. Generates directly.
@@ -53,7 +62,8 @@ Every extracted concept carries:
53
62
  - **low** — plausible reconstruction. Never auto-generated; surfaced for human review.
54
63
 
55
64
  Two further distinctions keep facts and guesses separate:
56
- - `events[].explicit: false` — the event was *reconstructed* from side-effect clusters (email + status flip), not emitted by the code.
65
+
66
+ - `events[].explicit: false` — the event was _reconstructed_ from side-effect clusters (email + status flip), not emitted by the code.
57
67
  - Comments/docs vs code: comments describe intent, code describes behavior. Disagreements are recorded as the code's behavior plus a `gaps.json` entry.
58
68
 
59
69
  ## Repo layout
@@ -2,36 +2,36 @@
2
2
 
3
3
  The `.knowledge/` blueprint is designed so each concept maps onto exactly one Pikku primitive. A generator (or an agent following `pikku-feature`) walks the JSON files in this order:
4
4
 
5
- | Blueprint source | Pikku target |
6
- |---|---|
7
- | `entities.json` attributes + relationships + constraints | Kysely migrations + generated `DB` types; Zod schemas per entity |
8
- | `entities.json` states/transitions | a `status` column + transition guards inside the owning commands (or a state-machine helper) |
9
- | `commands.json` | `pikkuFunc` / `pikkuSessionlessFunc` with `input:` Zod schema built from `input[]`; `preconditions` become guard clauses; name is the camelCased command name (`SendInvoice` → `sendInvoice`) |
10
- | `queries.json` | `pikkuFunc` reads; `scoping` becomes the mandatory `WHERE` / session filter |
11
- | `events.json` | EventHub topics (realtime) or queue messages; `consumedBy` become `wireQueueWorker` handlers — implicit events (`explicit: false`) get promoted to real emissions |
12
- | `policies.json` (authorization) | Pikku `permissions` / middleware; one policy = one named permission function, wired everywhere `enforcedAt` listed — this collapses duplicated legacy checks into a single definition |
13
- | `policies.json` (validation) | Zod schema refinements on the command's `input` |
14
- | `workflows.json` kind=user | frontend flows + the commands they chain |
15
- | `workflows.json` kind=system, with `schedule` | `wireScheduler` entries |
16
- | `workflows.json` multi-step / checkpointing | `pikkuWorkflowFunc` with one `workflow.do(...)` step per blueprint step |
17
- | `workflows.json` `scenarios[]` | **`pikkuUserFlow` stories — this is the canonical target.** Each scenario's given/when/outcome maps 1:1 onto a user-flow step sequence; group scenarios by their workflow into one flow per journey. Only scenarios with no user-facing surface (pure system workflows: cron sweeps, webhook ingest) fall back to API/e2e tests |
18
- | `api.json` | `wireHTTP` routes: keep `path`+`method` for compatibility, point at the mapped command/query func; `auth: none`/capability-URL surfaces get `auth: false` |
19
- | `api.json` kind=webhook-in | `wireHTTP` with `auth: false` + signature-verification middleware from the integration |
20
- | `integrations.json` | services in `services.ts` (constructor-injected classes); `configVia` env vars become `wireSecret` / config; per-user credentials become `wireCredential` |
21
- | `architecture.json` notes | deployment config (ports, raw-body routes, proxy expectations) |
22
- | `invariants.json` enforcedBy=db-constraint | migration constraints (UNIQUE, CHECK, FK) |
23
- | `invariants.json` enforcedBy=code-guard/nothing | guard clauses + a test each; `atRiskBecause` entries get a hardening task |
24
- | `gaps.json` | excluded from generation; `open-product-decision` + `migration.json.decisionsNeeded` go to a human BEFORE generation starts |
25
- | `migration.json.mappings` | the work plan: one mapping = one migration slice |
26
- | `interfaces.json` kind=cli | `wireCLI` entrypoints — the CLI commands are the same funcs the routes expose |
27
- | `interfaces.json` kind=mcp | `wireMCP` — each MCP tool IS a `pikkuFunc` (reuse the command/query funcs; don't author tool duplicates) |
28
- | `interfaces.json` kind=openapi-rest / sdk | generated, not hand-written: the OpenAPI spec + typed client SDK fall out of the `wireHTTP` routes + codegen |
29
- | `interfaces.json` kind=websocket-realtime | `pikku-realtime` EventHub topics / channels |
30
- | `frontend.json` | `apps/app` (TanStack Start) shell: router, `@pikku/mantine` theme, `pikku-react-query` data layer, `better-auth` client — the target stack the legacy UI is rebuilt onto |
31
- | `frontend-routes.json` | TanStack Router routes under `apps/app/src/routes/**` (thin data containers calling `usePikkuQuery`); `dataFrom` names become the generated hooks; subpath routes for rich detail views |
32
- | `frontend-components.json` rebuild=`mantine-standard`/`mantine-composition` | components in `packages/components` composed from `@pikku/mantine` — the trivial/straightforward bulk |
33
- | `frontend-components.json` rebuild=`custom-logic` | the PORT list — each becomes a `packages/components` component that reimplements the bespoke behavior (chart/table/editor); its `dependencies` inform whether the lib is kept or replaced. These are the frontend's real work items |
34
- | `frontend-components.json` rebuild=`custom-style` | normalize to Mantine/theme tokens; usually deleted-and-recomposed, not ported |
5
+ | Blueprint source | Pikku target |
6
+ | --------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
7
+ | `entities.json` attributes + relationships + constraints | Kysely migrations + generated `DB` types; Zod schemas per entity |
8
+ | `entities.json` states/transitions | a `status` column + transition guards inside the owning commands (or a state-machine helper) |
9
+ | `commands.json` | `pikkuFunc` / `pikkuSessionlessFunc` with `input:` Zod schema built from `input[]`; `preconditions` become guard clauses; name is the camelCased command name (`SendInvoice` → `sendInvoice`) |
10
+ | `queries.json` | `pikkuFunc` reads; `scoping` becomes the mandatory `WHERE` / session filter |
11
+ | `events.json` | EventHub topics (realtime) or queue messages; `consumedBy` become `wireQueueWorker` handlers — implicit events (`explicit: false`) get promoted to real emissions |
12
+ | `policies.json` (authorization) | Pikku `permissions` / middleware; one policy = one named permission function, wired everywhere `enforcedAt` listed — this collapses duplicated legacy checks into a single definition |
13
+ | `policies.json` (validation) | Zod schema refinements on the command's `input` |
14
+ | `workflows.json` kind=user | frontend flows + the commands they chain |
15
+ | `workflows.json` kind=system, with `schedule` | `wireScheduler` entries |
16
+ | `workflows.json` multi-step / checkpointing | `pikkuWorkflowFunc` with one `workflow.do(...)` step per blueprint step |
17
+ | `workflows.json` `scenarios[]` | **`pikkuUserFlow` stories — this is the canonical target.** Each scenario's given/when/outcome maps 1:1 onto a user-flow step sequence; group scenarios by their workflow into one flow per journey. Only scenarios with no user-facing surface (pure system workflows: cron sweeps, webhook ingest) fall back to API/e2e tests |
18
+ | `api.json` | `wireHTTP` routes: keep `path`+`method` for compatibility, point at the mapped command/query func; `auth: none`/capability-URL surfaces get `auth: false` |
19
+ | `api.json` kind=webhook-in | `wireHTTP` with `auth: false` + signature-verification middleware from the integration |
20
+ | `integrations.json` | services in `services.ts` (constructor-injected classes); `configVia` env vars become `defineSecret` / config; per-user credentials become `defineCredential` |
21
+ | `architecture.json` notes | deployment config (ports, raw-body routes, proxy expectations) |
22
+ | `invariants.json` enforcedBy=db-constraint | migration constraints (UNIQUE, CHECK, FK) |
23
+ | `invariants.json` enforcedBy=code-guard/nothing | guard clauses + a test each; `atRiskBecause` entries get a hardening task |
24
+ | `gaps.json` | excluded from generation; `open-product-decision` + `migration.json.decisionsNeeded` go to a human BEFORE generation starts |
25
+ | `migration.json.mappings` | the work plan: one mapping = one migration slice |
26
+ | `interfaces.json` kind=cli | `wireCLI` entrypoints — the CLI commands are the same funcs the routes expose |
27
+ | `interfaces.json` kind=mcp | `wireMCP` — each MCP tool IS a `pikkuFunc` (reuse the command/query funcs; don't author tool duplicates) |
28
+ | `interfaces.json` kind=openapi-rest / sdk | generated, not hand-written: the OpenAPI spec + typed client SDK fall out of the `wireHTTP` routes + codegen |
29
+ | `interfaces.json` kind=websocket-realtime | `pikku-realtime` EventHub topics / channels |
30
+ | `frontend.json` | `apps/app` (TanStack Start) shell: router, `@pikku/mantine` theme, `pikku-react-query` data layer, `better-auth` client — the target stack the legacy UI is rebuilt onto |
31
+ | `frontend-routes.json` | TanStack Router routes under `apps/app/src/routes/**` (thin data containers calling `usePikkuQuery`); `dataFrom` names become the generated hooks; subpath routes for rich detail views |
32
+ | `frontend-components.json` rebuild=`mantine-standard`/`mantine-composition` | components in `packages/components` composed from `@pikku/mantine` — the trivial/straightforward bulk |
33
+ | `frontend-components.json` rebuild=`custom-logic` | the PORT list — each becomes a `packages/components` component that reimplements the bespoke behavior (chart/table/editor); its `dependencies` inform whether the lib is kept or replaced. These are the frontend's real work items |
34
+ | `frontend-components.json` rebuild=`custom-style` | normalize to Mantine/theme tokens; usually deleted-and-recomposed, not ported |
35
35
 
36
36
  ## Order of generation
37
37
 
@@ -18,11 +18,16 @@ commit, separate from any feature work.
18
18
  _template_, not the user's project — leaving it in place is misleading.
19
19
  Either delete it (`git rm README.md`) or rewrite it with the new project's
20
20
  name and purpose. Never ship a clone with the generic template README.
21
- 2. **Keep the lockfile committed.** Templates ship a committed `yarn.lock`; do
22
- NOT re-add `yarn.lock` to `.gitignore`. A real project commits its lockfile
23
- for reproducible installs. The correct pattern is `yarn.lock` followed by
24
- `!/yarn.lock`, which commits the root lockfile while keeping generated
25
- per-unit lockfiles under `.deploy/` (and `e2e/`) ignored.
21
+ 2. **Keep the lockfile committed.** Do NOT re-add `yarn.lock` to `.gitignore`.
22
+ A real project commits its lockfile for reproducible installs. The correct
23
+ pattern is `yarn.lock` followed by `!/yarn.lock`, which commits the root
24
+ lockfile while keeping generated per-unit lockfiles under `.deploy/` (and
25
+ `e2e/`) ignored.
26
+
27
+ `create-pikku` keeps only the chosen package manager's lockfile and deletes
28
+ the other, and for yarn it may have written an **empty** `yarn.lock` as a
29
+ marker. Commit the lockfile *after* the first install has filled it in —
30
+ committing the empty placeholder pins nothing.
26
31
  3. **Rename template identifiers.** Update `name` in the root `package.json`
27
32
  (and any `@project/*` or other placeholder names) to the real project.
28
33
  4. **Drop template-only artifacts.** Remove any `TEMPLATE.md`, demo docs, or
@@ -35,16 +35,23 @@ See `pikku-concepts` for the core mental model.
35
35
 
36
36
  ## API Reference
37
37
 
38
+ All three come from `#pikku`. A trigger is deliberately split in two: the
39
+ **source** owns the connection to the outside world and the **trigger** names the
40
+ function to run, so one source can be swapped (Redis → PG) without touching the
41
+ handler, and a handler can exist before any source is wired.
42
+
38
43
  ### `wireTrigger(config)`
39
44
 
40
45
  Define the target function that handles trigger events:
41
46
 
42
47
  ```typescript
43
- import { wireTrigger } from '@pikku/core/trigger'
48
+ import { wireTrigger } from '#pikku'
44
49
 
45
50
  wireTrigger({
46
51
  name: string, // Trigger name (matches source)
47
52
  func: PikkuFunc, // Function to call when event fires
53
+ description?: string,
54
+ tags?: string[],
48
55
  })
49
56
  ```
50
57
 
@@ -53,18 +60,24 @@ wireTrigger({
53
60
  Define the event source that fires triggers:
54
61
 
55
62
  ```typescript
56
- import { wireTriggerSource } from '@pikku/core/trigger'
63
+ import { wireTriggerSource } from '#pikku'
57
64
 
58
65
  wireTriggerSource({
59
- name: string, // Must match wireTrigger name
60
- func: PikkuTriggerFunc, // Source function (sets up listener)
61
- input: object, // Configuration for the source
66
+ name: string, // Must match a wireTrigger name
67
+ func: PikkuTriggerFunc, // Source function (sets up the listener)
68
+ input: object, // Configuration handed to the source
62
69
  })
63
70
  ```
64
71
 
72
+ `input` is required whenever the source function declares an input type, and the
73
+ name must be unique — wiring the same source name twice throws
74
+ `Trigger source already exists`.
75
+
65
76
  ### `pikkuTriggerFunc<TInput, TEvent>`
66
77
 
67
- Define a trigger source function. Returns a cleanup function.
78
+ A trigger source function runs **once at startup**, not once per event. It sets
79
+ up a listener, calls `trigger.invoke(...)` for each event it sees, and returns a
80
+ teardown function:
68
81
 
69
82
  ```typescript
70
83
  import { pikkuTriggerFunc } from '#pikku'
@@ -83,6 +96,37 @@ const source = pikkuTriggerFunc<
83
96
  })
84
97
  ```
85
98
 
99
+ It receives **singleton services only** — there is no session, no request and no
100
+ per-wire services, because a listener outlives every event it will ever emit.
101
+ The config-object form (`pikkuTriggerFunc({ func, title, description, tags,
102
+ input, output })`) is also accepted when you want schemas or metadata on the
103
+ source.
104
+
105
+ ## Starting triggers
106
+
107
+ Nothing fires until a `TriggerService` is started. For a single process,
108
+ `InMemoryTriggerService` walks every wired source that has at least one matching
109
+ target and sets it up:
110
+
111
+ ```typescript
112
+ import { InMemoryTriggerService } from '@pikku/core/services'
113
+
114
+ const triggerService = new InMemoryTriggerService()
115
+ await triggerService.start()
116
+ // on shutdown
117
+ await triggerService.stop() // runs every source's teardown
118
+ ```
119
+
120
+ A source with no matching `wireTrigger` is logged and skipped rather than
121
+ erroring — the two halves are wired independently, so a half-wired trigger is a
122
+ normal intermediate state.
123
+
124
+ **If a wiring is silently skipped**, look for
125
+ `Skipping trigger … metadata not found` in the logs. Both wirings read metadata
126
+ generated by the inspector, and it warns rather than throwing; the usual fix is
127
+ the one the warning suggests — move the wiring into its own file so codegen
128
+ picks it up.
129
+
86
130
  ## Usage Patterns
87
131
 
88
132
  ### Redis Pub/Sub Source