@pikku/skills 0.12.4 → 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 (64) 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 +45 -10
  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 +75 -10
  14. package/skills/pikku-config/SKILL.md +56 -14
  15. package/skills/pikku-cron/SKILL.md +13 -6
  16. package/skills/pikku-deploy-azure/SKILL.md +83 -28
  17. package/skills/pikku-deploy-cloudflare/SKILL.md +79 -37
  18. package/skills/pikku-deploy-express/SKILL.md +40 -4
  19. package/skills/pikku-deploy-fastify/SKILL.md +22 -1
  20. package/skills/pikku-deploy-lambda/SKILL.md +99 -19
  21. package/skills/pikku-deploy-nextjs/SKILL.md +49 -5
  22. package/skills/pikku-deploy-uws/SKILL.md +54 -1
  23. package/skills/pikku-deps/SKILL.md +29 -8
  24. package/skills/pikku-emails/SKILL.md +36 -5
  25. package/skills/pikku-fabric/SKILL.md +27 -2
  26. package/skills/pikku-fabric-debug/SKILL.md +5 -1
  27. package/skills/pikku-feature/SKILL.md +6 -1
  28. package/skills/pikku-gateway-slack/SKILL.md +72 -11
  29. package/skills/pikku-http/SKILL.md +18 -5
  30. package/skills/pikku-http/references/http-options.md +10 -5
  31. package/skills/pikku-i18n/SKILL.md +18 -7
  32. package/skills/pikku-info/SKILL.md +18 -8
  33. package/skills/pikku-jose/SKILL.md +35 -6
  34. package/skills/pikku-kysely/SKILL.md +78 -15
  35. package/skills/pikku-machine-auth/SKILL.md +36 -1
  36. package/skills/pikku-mcp/SKILL.md +159 -149
  37. package/skills/pikku-middleware/SKILL.md +17 -5
  38. package/skills/pikku-mongodb/SKILL.md +10 -2
  39. package/skills/pikku-n8n-import/SKILL.md +14 -6
  40. package/skills/pikku-permissions/SKILL.md +102 -22
  41. package/skills/pikku-pino/SKILL.md +12 -4
  42. package/skills/pikku-product-second-opinion/SKILL.md +3 -3
  43. package/skills/pikku-queue/SKILL.md +45 -16
  44. package/skills/pikku-react/SKILL.md +41 -14
  45. package/skills/pikku-react-query/SKILL.md +14 -10
  46. package/skills/pikku-realtime/SKILL.md +44 -22
  47. package/skills/pikku-redis/SKILL.md +12 -3
  48. package/skills/pikku-rpc/SKILL.md +23 -12
  49. package/skills/pikku-rtl/SKILL.md +21 -17
  50. package/skills/pikku-scenario/SKILL.md +108 -75
  51. package/skills/pikku-schedule/SKILL.md +39 -6
  52. package/skills/pikku-schema-ajv/SKILL.md +24 -2
  53. package/skills/pikku-schema-cfworker/SKILL.md +22 -2
  54. package/skills/pikku-security/SKILL.md +54 -9
  55. package/skills/pikku-services/SKILL.md +49 -9
  56. package/skills/pikku-services/references/audit-wire-service.md +2 -1
  57. package/skills/pikku-template-clone/SKILL.md +10 -5
  58. package/skills/pikku-trigger/SKILL.md +50 -6
  59. package/skills/pikku-versioning/SKILL.md +46 -17
  60. package/skills/pikku-websocket/SKILL.md +72 -44
  61. package/skills/pikku-workflow/SKILL.md +35 -1
  62. package/skills/pikku-workflow/references/workflow-reference.md +13 -8
  63. package/skills/pikku-workflows-client/SKILL.md +13 -6
  64. package/skills/pikku-ws/SKILL.md +44 -8
@@ -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
  })
@@ -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
@@ -34,20 +34,22 @@ See `pikku-concepts` for the core mental model.
34
34
 
35
35
  ## Function Versioning
36
36
 
37
- When you need to introduce a breaking change, keep the old function as a pinned version and let the new one become the latest.
37
+ A function with `version: N` is registered under the id `name@vN`. The bare
38
+ name still resolves to it, so callers that don't care about versions keep
39
+ working while a pinned `getBook@v1` stays addressable for the ones that do.
38
40
 
39
- **The pattern:**
41
+ **The pattern:** when you need to introduce a breaking change, copy the current
42
+ function into a pinned `v1` and bump the live one to `version: 2`.
40
43
 
41
- 1. Create a new file `my-function-v1.function.ts` — export a variable with the `V1` suffix
42
- 2. Set `override: 'myFunction'` — this is the contract key the manifest groups under
43
- 3. Set `version: 1` — pins this as version 1 of the contract
44
- 4. The existing `my-function.function.ts` (no `version:` field) automatically becomes the latest version
44
+ 1. Create `my-function-v1.function.ts` exporting `getBookV1` with `version: 1` —
45
+ the trailing `V1` matching the version is stripped automatically, so the id
46
+ becomes `getBook@v1`
47
+ 2. Add `version: 2` to the existing `getBook`
45
48
 
46
49
  ```typescript
47
50
  // my-function-v1.function.ts — old contract, kept for running workflows/agents
48
51
  export const getBookV1 = pikkuFunc({
49
- override: 'getBook', // REQUIRED — links this to the 'getBook' contract family
50
- version: 1,
52
+ version: 1, // id becomes getBook@v1 — the V1 suffix is stripped
51
53
  input: z.object({ bookId: z.string() }),
52
54
  output: z.object({ title: z.string() }),
53
55
  func: async ({ db }, { bookId }) => {
@@ -55,8 +57,9 @@ export const getBookV1 = pikkuFunc({
55
57
  },
56
58
  })
57
59
 
58
- // my-function.function.ts — latest contract, no version: field
60
+ // my-function.function.ts — latest contract, id becomes getBook@v2
59
61
  export const getBook = pikkuFunc({
62
+ version: 2,
60
63
  input: z.object({
61
64
  bookId: z.string(),
62
65
  format: z.enum(['full', 'summary']),
@@ -72,7 +75,17 @@ export const getBook = pikkuFunc({
72
75
  })
73
76
  ```
74
77
 
75
- **Why `override` is required:** The manifest groups functions by a shared contract key. Without `override: 'getBook'`, `getBookV1` is stored internally as `getBookV1@v1` (key: `getBookV1`), which is a different contract family from `getBook`. With `override: 'getBook'`, it becomes `getBook@v1` (key: `getBook`), which groups with the unversioned `getBook` — and the unversioned one is automatically promoted to `getBook@v2`.
78
+ **Bump the live function explicitly.** Nothing promotes an unversioned function
79
+ to the next version for you — without `version: 2` it is treated as version 1 of
80
+ the `getBook` contract, colliding with the pinned `getBook@v1` and making
81
+ `versions check` report the published contract as modified.
82
+
83
+ **`override` is the escape hatch, not the requirement.** The contract key comes
84
+ from the exported name with a matching `V<n>` suffix removed, so
85
+ `getBookV1` + `version: 1` already lands on `getBook`. Use
86
+ `override: 'getBook'` only when the export can't follow that convention — for
87
+ instance `legacyGetBook` with `version: 1`, which would otherwise key under
88
+ `legacyGetBook`.
76
89
 
77
90
  ## Version Manifest (`versions.pikku.json`)
78
91
 
@@ -99,22 +112,38 @@ Pikku tracks contract hashes to detect breaking changes:
99
112
  }
100
113
  ```
101
114
 
102
- Each hash is derived from the function's input and output schemas plus the contract key. If a schema changes without a version bump, `pikku versions check` will fail.
115
+ Each hash is derived from the function's input and output schemas plus the
116
+ contract key. If a schema changes without a version bump, `pikku versions check`
117
+ will fail.
118
+
119
+ The manifest lives at `versions.pikku.json` in the project's `rootDir`, and its
120
+ presence is what switches versioning on — with no manifest, nothing is checked.
103
121
 
104
122
  ## CLI Commands
105
123
 
106
124
  ```bash
107
- npx pikku versions init # Initialize versioning manifest (run once)
125
+ npx pikku versions init # Create an empty versioning manifest (run once)
108
126
  npx pikku versions check # Detect contract changes (use in CI)
109
- npx pikku versions update # Update contract hashes after version bump
127
+ npx pikku versions update # Record current contract hashes
110
128
  ```
111
129
 
130
+ `init` writes `{ "manifestVersion": 1, "contracts": {} }` and nothing more — it
131
+ does **not** capture the hashes of the functions you already have. Run
132
+ `versions update` straight after it to record the current state, otherwise
133
+ `check` has nothing to compare against and silently passes.
134
+
135
+ `update` refuses to save when a published version's hash changed, so it can
136
+ never overwrite an immutable record; it reports that as a diagnostic and leaves
137
+ the manifest alone. Fix the contract or bump the version, then run it again.
138
+
112
139
  **Workflow:**
113
140
 
114
- 1. `pikku versions init` — run once to create `versions.pikku.json`
141
+ 1. `pikku versions init` then `pikku versions update` — once, to create and
142
+ populate `versions.pikku.json`
115
143
  2. Develop normally — add/modify functions
116
144
  3. `pikku versions check` — CI catches unversioned breaking changes
117
- 4. If intentional: create `my-function-v1.function.ts` with `override` + `version: 1`, then `pikku versions update`
145
+ 4. If intentional: pin the old contract as `…V1` with `version: 1`, bump the
146
+ live function to `version: 2`, then `pikku versions update`
118
147
 
119
148
  ## CI Integration
120
149
 
@@ -135,9 +164,8 @@ jobs:
135
164
  ## Complete Example
136
165
 
137
166
  ```typescript
138
- // create-todo-v1.function.ts — v1 locked contract
167
+ // create-todo-v1.function.ts — v1 locked contract, id: createTodo@v1
139
168
  export const createTodoV1 = pikkuSessionlessFunc({
140
- override: 'createTodo', // groups under 'createTodo' contract family
141
169
  version: 1,
142
170
  input: z.object({ title: z.string() }),
143
171
  output: z.object({ id: z.string(), title: z.string() }),
@@ -146,6 +174,7 @@ export const createTodoV1 = pikkuSessionlessFunc({
146
174
 
147
175
  // create-todo.function.ts — v2 (latest), called by default
148
176
  export const createTodo = pikkuSessionlessFunc({
177
+ version: 2,
149
178
  input: z.object({
150
179
  title: z.string(),
151
180
  priority: z.enum(['low', 'medium', 'high']),