@pikku/skills 0.12.4 → 0.12.8
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.
- package/dist/skills.gen.js +1 -1
- package/package.json +1 -1
- package/skills/pikku-addon/SKILL.md +74 -33
- package/skills/pikku-ai-agent/SKILL.md +197 -105
- package/skills/pikku-ai-vercel/SKILL.md +57 -18
- package/skills/pikku-ai-voice/SKILL.md +126 -52
- package/skills/pikku-audit/SKILL.md +35 -13
- package/skills/pikku-aws/SKILL.md +66 -16
- package/skills/pikku-backblaze/SKILL.md +44 -11
- package/skills/pikku-better-auth/SKILL.md +45 -10
- package/skills/pikku-cli/SKILL.md +67 -18
- package/skills/pikku-cli/references/complete-example.md +2 -0
- package/skills/pikku-concepts/SKILL.md +75 -10
- package/skills/pikku-config/SKILL.md +56 -14
- package/skills/pikku-cron/SKILL.md +13 -6
- package/skills/pikku-deploy-azure/SKILL.md +83 -28
- package/skills/pikku-deploy-cloudflare/SKILL.md +79 -37
- package/skills/pikku-deploy-express/SKILL.md +40 -4
- package/skills/pikku-deploy-fastify/SKILL.md +22 -1
- package/skills/pikku-deploy-lambda/SKILL.md +99 -19
- package/skills/pikku-deploy-nextjs/SKILL.md +49 -5
- package/skills/pikku-deploy-uws/SKILL.md +54 -1
- package/skills/pikku-deps/SKILL.md +29 -8
- package/skills/pikku-emails/SKILL.md +36 -5
- package/skills/pikku-fabric/SKILL.md +27 -2
- package/skills/pikku-fabric-debug/SKILL.md +5 -1
- package/skills/pikku-feature/SKILL.md +6 -1
- package/skills/pikku-gateway-slack/SKILL.md +72 -11
- package/skills/pikku-http/SKILL.md +18 -5
- package/skills/pikku-http/references/http-options.md +10 -5
- package/skills/pikku-i18n/SKILL.md +18 -7
- package/skills/pikku-info/SKILL.md +18 -8
- package/skills/pikku-jose/SKILL.md +35 -6
- package/skills/pikku-knowledge/SKILL.md +50 -7
- package/skills/pikku-kysely/SKILL.md +78 -15
- package/skills/pikku-machine-auth/SKILL.md +36 -1
- package/skills/pikku-mcp/SKILL.md +159 -149
- package/skills/pikku-middleware/SKILL.md +17 -5
- package/skills/pikku-mongodb/SKILL.md +10 -2
- package/skills/pikku-n8n-import/SKILL.md +14 -6
- package/skills/pikku-permissions/SKILL.md +102 -22
- package/skills/pikku-pino/SKILL.md +12 -4
- package/skills/pikku-product-second-opinion/SKILL.md +3 -3
- package/skills/pikku-queue/SKILL.md +45 -16
- package/skills/pikku-react/SKILL.md +41 -14
- package/skills/pikku-react-query/SKILL.md +14 -10
- package/skills/pikku-realtime/SKILL.md +44 -22
- package/skills/pikku-redis/SKILL.md +12 -3
- package/skills/pikku-rpc/SKILL.md +23 -12
- package/skills/pikku-rtl/SKILL.md +21 -17
- package/skills/pikku-scenario/SKILL.md +141 -76
- package/skills/pikku-schedule/SKILL.md +39 -6
- package/skills/pikku-schema-ajv/SKILL.md +24 -2
- package/skills/pikku-schema-cfworker/SKILL.md +22 -2
- package/skills/pikku-security/SKILL.md +54 -9
- package/skills/pikku-services/SKILL.md +49 -9
- package/skills/pikku-services/references/audit-wire-service.md +2 -1
- package/skills/pikku-template-clone/SKILL.md +10 -5
- package/skills/pikku-trigger/SKILL.md +50 -6
- package/skills/pikku-versioning/SKILL.md +46 -17
- package/skills/pikku-websocket/SKILL.md +72 -44
- package/skills/pikku-workflow/SKILL.md +123 -11
- package/skills/pikku-workflow/references/workflow-reference.md +13 -8
- package/skills/pikku-workflows-client/SKILL.md +13 -6
- package/skills/pikku-ws/SKILL.md +44 -8
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: pikku-permissions
|
|
3
3
|
description: >-
|
|
4
|
-
Use when adding authorization checks to Pikku functions — pikkuPermission, pikkuAuth,
|
|
5
|
-
per-function permissions, global permissions, or understanding OR/AND
|
|
6
|
-
TRIGGER when: user wants to restrict who can call a function, check resource
|
|
7
|
-
role-based
|
|
8
|
-
|
|
4
|
+
Use when adding authorization checks to Pikku functions — pikkuPermission, pikkuAuth, scopes and
|
|
5
|
+
defineScope, per-function permissions, global permissions, or understanding the scope/OR/AND
|
|
6
|
+
gating logic. TRIGGER when: user wants to restrict who can call a function, check resource
|
|
7
|
+
ownership, add role-based or scope-based access, declares or grants scopes, hits
|
|
8
|
+
MissingScopeError, or asks where permission checks belong. DO NOT TRIGGER when: user asks about
|
|
9
|
+
middleware or request interception (use pikku-middleware), authentication strategies (use
|
|
9
10
|
pikku-security), or session management.
|
|
10
11
|
installGroups: [core]
|
|
11
12
|
---
|
|
@@ -16,7 +17,7 @@ installGroups: [core]
|
|
|
16
17
|
|
|
17
18
|
**ALWAYS put authorization checks in the `permissions` field of `pikkuFunc` or `pikkuSessionlessFunc` — NEVER inside the `func` body.**
|
|
18
19
|
|
|
19
|
-
This includes: org access checks, repo access checks, role checks, resource ownership, and any other authorization logic. The `permissions` field runs before `func
|
|
20
|
+
This includes: org access checks, repo access checks, role checks, resource ownership, and any other authorization logic. The `permissions` field runs before `func` and is visible to the inspector, so the gate is declared rather than buried — which is what lets `pikku info permissions` and an audit see it at all. Alongside it sits `scopes` (see below) for grant-based gating; between them they are where Pikku enforces authorization. The one sanctioned exception is `permissionsInBody`, covered at the end.
|
|
20
21
|
|
|
21
22
|
```typescript
|
|
22
23
|
// CORRECT
|
|
@@ -104,11 +105,11 @@ export const hasBookAccess = pikkuPermission(
|
|
|
104
105
|
|
|
105
106
|
```typescript
|
|
106
107
|
permissions: {
|
|
107
|
-
verified: isVerified,
|
|
108
|
-
owner: isBookOwner,
|
|
109
|
-
reviewer: [
|
|
108
|
+
verified: isVerified, // OR: verified users can access
|
|
109
|
+
owner: isBookOwner, // OR: owners can access
|
|
110
|
+
reviewer: [isVerified, hasBookAccess], // AND: both must pass
|
|
110
111
|
}
|
|
111
|
-
// Logic: verified OR owner OR (
|
|
112
|
+
// Logic: verified OR owner OR (isVerified AND hasBookAccess)
|
|
112
113
|
```
|
|
113
114
|
|
|
114
115
|
Groups are OR'd. Entries within a group array are AND'd.
|
|
@@ -134,23 +135,106 @@ export const deleteBook = pikkuFunc({
|
|
|
134
135
|
A global permission is an app-wide baseline that **every** function must additionally pass. It is an independent AND gate: it can only ever *narrow* access — it never grants access a function's own `permissions` would deny.
|
|
135
136
|
|
|
136
137
|
```typescript
|
|
137
|
-
import { addGlobalPermission } from '
|
|
138
|
+
import { addGlobalPermission } from '#pikku'
|
|
138
139
|
|
|
139
|
-
addGlobalPermission([
|
|
140
|
+
addGlobalPermission([isEmployee]) // every function now also requires an employee session
|
|
140
141
|
```
|
|
141
142
|
|
|
142
143
|
Multiple `addGlobalPermission` calls accumulate and are AND'd together.
|
|
143
144
|
|
|
144
145
|
> Wire-, tag-, and HTTP-route-level permissions (`addHTTPPermission`, `addTagPermission`, and a `permissions` field on HTTP/channel/MCP wirings) were **removed in #972**. Permissions now live only on the function definition, plus the optional global gate. Tags are organizational only — use tag/HTTP *middleware* (`addTagMiddleware`, `addHTTPMiddleware`) for cross-cutting request handling, not authorization.
|
|
145
146
|
|
|
146
|
-
##
|
|
147
|
+
## Scopes — the AND Gate Above Permissions
|
|
147
148
|
|
|
148
|
-
|
|
149
|
+
Scopes answer "what was this session granted?" before permissions ask "may this
|
|
150
|
+
user do this to this resource?". They are AND-ed: every scope listed must be
|
|
151
|
+
held. Because they are checked first and fail closed, a scope can only ever
|
|
152
|
+
*narrow* access — it never grants what `permissions` would deny.
|
|
149
153
|
|
|
150
|
-
|
|
151
|
-
|
|
154
|
+
Declare the scope tree once with `defineScope`. The body is a no-op that
|
|
155
|
+
tree-shakes away; the CLI reads the call by AST and generates a `ScopeId` union,
|
|
156
|
+
so a function naming an undeclared scope fails the build rather than silently
|
|
157
|
+
gating on nothing.
|
|
152
158
|
|
|
153
|
-
|
|
159
|
+
```typescript
|
|
160
|
+
// src/scopes.ts
|
|
161
|
+
import { defineScope } from '#pikku'
|
|
162
|
+
|
|
163
|
+
defineScope({
|
|
164
|
+
admin: {
|
|
165
|
+
displayName: 'Administration',
|
|
166
|
+
description: 'Administrative access',
|
|
167
|
+
scopes: {
|
|
168
|
+
invoices: {
|
|
169
|
+
description: 'Invoice management',
|
|
170
|
+
scopes: {
|
|
171
|
+
create: { description: 'Create invoices' },
|
|
172
|
+
void: { description: 'Void invoices' },
|
|
173
|
+
},
|
|
174
|
+
},
|
|
175
|
+
},
|
|
176
|
+
},
|
|
177
|
+
billing: {},
|
|
178
|
+
})
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
Every node is grantable, keyed by segment: the above yields `admin`,
|
|
182
|
+
`admin:invoices`, `admin:invoices:create`, `admin:invoices:void` and `billing`.
|
|
183
|
+
Scopes may be declared across more than one file — the declarations merge.
|
|
184
|
+
|
|
185
|
+
```typescript
|
|
186
|
+
export const voidInvoice = pikkuFunc({
|
|
187
|
+
scopes: ['admin:invoices:void'],
|
|
188
|
+
permissions: { owner: isInvoiceOwner },
|
|
189
|
+
func: async ({ db }, { invoiceId }) => { ... },
|
|
190
|
+
})
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
A grant satisfies a required scope if it is the scope itself, an ancestor of it,
|
|
194
|
+
or a wildcard at any level — so a session holding `admin` satisfies
|
|
195
|
+
`admin:invoices:void`, and `admin:*` does too. A missing scope throws
|
|
196
|
+
`MissingScopeError` naming the first one that failed.
|
|
197
|
+
|
|
198
|
+
`scopes` requires a session and so is unavailable on `pikkuSessionlessFunc`:
|
|
199
|
+
scopes fail closed, an anonymous caller holds none, and a sessionless function
|
|
200
|
+
with scopes would reject every caller it exists to serve. Gate those with
|
|
201
|
+
`permissions`, which receive the optional session and may pass anonymous.
|
|
202
|
+
|
|
203
|
+
## The Three Gates
|
|
204
|
+
|
|
205
|
+
Authorization is three independent gates, evaluated in this order, all of which must pass:
|
|
206
|
+
|
|
207
|
+
1. **Scopes** (`scopes`) — AND'd, checked before input validation. Fails closed.
|
|
208
|
+
2. **Global permissions** (`addGlobalPermission`) — AND'd together. A broad baseline that can only narrow access.
|
|
209
|
+
3. **The function's own `permissions`** — OR'd groups (OR-of-ANDs), as above.
|
|
210
|
+
|
|
211
|
+
The gates are independent: a broad global (e.g. `isEmployee`) can **never** satisfy an admin-only function's own requirement. Each function still enforces its own `scopes` and `permissions` in full.
|
|
212
|
+
|
|
213
|
+
## The Sanctioned Exception: `permissionsInBody`
|
|
214
|
+
|
|
215
|
+
A few checks genuinely cannot be expressed as a permission — verifying a webhook
|
|
216
|
+
signature, a signed token, or an invite code, where the "identity" arrives in the
|
|
217
|
+
payload and there is no session to check. For those, declare
|
|
218
|
+
`permissionsInBody: true` on the function and keep the check in the body.
|
|
219
|
+
|
|
220
|
+
```typescript
|
|
221
|
+
export const handleStripeWebhook = pikkuSessionlessFunc({
|
|
222
|
+
permissionsInBody: true,
|
|
223
|
+
auth: false,
|
|
224
|
+
func: async ({ stripe }, data, { http }) => {
|
|
225
|
+
stripe.webhooks.constructEvent(data.raw, http.request.header('stripe-signature'), secret)
|
|
226
|
+
// ...
|
|
227
|
+
},
|
|
228
|
+
})
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
This is a last resort, and it is purely declarative — it grants nothing and
|
|
232
|
+
enforces nothing. Its only job is to tell the auditor that this function's
|
|
233
|
+
apparent openness is deliberate, so asserting it falsely disables the very check
|
|
234
|
+
that would have caught the mistake. It requires `"allow": { "permissionsInBody": true }`
|
|
235
|
+
in `pikku.config.json`, which keeps the decision visible at the project level.
|
|
236
|
+
Prefer `permissions` whenever the check can be expressed as one — they are
|
|
237
|
+
declared, inspectable, and reusable.
|
|
154
238
|
|
|
155
239
|
## Complete Example
|
|
156
240
|
|
|
@@ -158,10 +242,6 @@ The gates are independent: a broad global (e.g. `signedInUser`) can **never** sa
|
|
|
158
242
|
// src/permissions.ts
|
|
159
243
|
import { pikkuAuth, pikkuPermission } from '#pikku'
|
|
160
244
|
|
|
161
|
-
export const isAuthenticated = pikkuAuth(
|
|
162
|
-
async (_services, session) => !!session
|
|
163
|
-
)
|
|
164
|
-
|
|
165
245
|
export const isVerified = pikkuAuth(
|
|
166
246
|
async (_services, session) => !!session?.emailVerified
|
|
167
247
|
)
|
|
@@ -179,7 +259,7 @@ export const deleteOrg = pikkuFunc({
|
|
|
179
259
|
},
|
|
180
260
|
permissions: {
|
|
181
261
|
verified: isVerified,
|
|
182
|
-
owner: [
|
|
262
|
+
owner: [isVerified, isOrgMember],
|
|
183
263
|
},
|
|
184
264
|
})
|
|
185
265
|
```
|
|
@@ -46,10 +46,18 @@ No constructor parameters. Creates a Pino logger instance.
|
|
|
46
46
|
**Methods:**
|
|
47
47
|
|
|
48
48
|
- `setLevel(level: LogLevel): void` — Set minimum log level.
|
|
49
|
-
- `info(messageOrObj: string | Record<string, any> | Error): void`
|
|
50
|
-
- `warn(messageOrObj: string | Record<string, any> | Error): void`
|
|
51
|
-
- `error(messageOrObj: string | Record<string, any> | Error): void`
|
|
52
|
-
- `debug(
|
|
49
|
+
- `info(messageOrObj: string | Record<string, any> | Error, ...meta): void`
|
|
50
|
+
- `warn(messageOrObj: string | Record<string, any> | Error, ...meta): void`
|
|
51
|
+
- `error(messageOrObj: string | Record<string, any> | Error, ...meta): void`
|
|
52
|
+
- `debug(message: string, ...meta): void` — string only; the object form is not accepted here
|
|
53
|
+
|
|
54
|
+
Every argument, first and trailing, is `Safe<>`-guarded. A `SecretValue` nested
|
|
55
|
+
anywhere in what you log collapses the call to `never` and it stops compiling.
|
|
56
|
+
An unrevealed secret would print as `[secret]` regardless — the guard is what
|
|
57
|
+
makes logging one a deliberate act rather than an accident.
|
|
58
|
+
|
|
59
|
+
`setLevel` maps Pikku's `LogLevel` enum onto Pino's own level strings, so pass
|
|
60
|
+
the enum (or its name) rather than a raw Pino level.
|
|
53
61
|
|
|
54
62
|
## Usage Patterns
|
|
55
63
|
|
|
@@ -120,12 +120,12 @@ Include this section **only if you are actually recommending a rebuild** onto it
|
|
|
120
120
|
|
|
121
121
|
**TanStack Start (the web framework) — instead of the incumbent (Next.js).**
|
|
122
122
|
- *Buys you:* modern, tidy developer experience; strong type-safety that catches whole classes of bugs before users see them; fast iteration; fine-grained control; deploys well to modern/edge hosting. The libraries underneath it — the TanStack ecosystem (Query, Router, Table) — are mature, battle-tested and everywhere in React.
|
|
123
|
-
- *Costs you (the honest tradeoff — maturity of the framework itself):* separate the ecosystem from the framework. Query/Router/Table are mature;
|
|
124
|
-
- *Usually:* a credible, modern choice on a mature foundation
|
|
123
|
+
- *Costs you (the honest tradeoff — maturity of the framework itself):* separate the ecosystem from the framework. Query/Router/Table are mature; the framework that wraps them is younger, and **you must check its release stage on tanstack.com/start at the moment you write** — do not infer it from the npm version. `@tanstack/react-start` has been on 1.x since early 2025 because its major tracks the **Router** version line, so "1.168.x" says nothing about whether Start itself has shipped a stable 1.0. If it is still pre-1.0, the cost is pinning an exact version and budgeting for upgrade work as it settles. Either way it is newer than the incumbent (Next.js), which has the largest ecosystem — fewer ready-made templates and third-party examples, and a smaller (though growing) pool of developers who've used *this specific* framework, which can make hiring slightly slower.
|
|
124
|
+
- *Usually:* a credible, modern choice on a mature foundation. Whether it also carries pinning-and-upgrade risk depends on the release stage you just checked — say which you found, rather than repeating either verdict from here.
|
|
125
125
|
|
|
126
126
|
**Pikku (the framework a rebuild would land on) — instead of staying where you are.**
|
|
127
127
|
- *Buys you:* one way to write a capability and drive it from anywhere — web, background jobs, timers, realtime, AI assistants, the command line — so a feature is written once instead of five times. Type-safe clients and the API spec fall out of the code rather than being hand-maintained until they drift. The sprawl an organically-grown app accumulates collapses into one shape a small team can hold in its head.
|
|
128
|
-
- *Costs you (the honest tradeoff — it is younger than anything it would replace):* Pikku has **not shipped a stable 1.0** — it
|
|
128
|
+
- *Costs you (the honest tradeoff — it is younger than anything it would replace):* Pikku has **not shipped a stable 1.0** — at the time of writing it is 0.12.x, and 0.13 is the first release that promises backwards compatibility; check the published version rather than repeating this one. Until then upgrades can break you. In practice: pin your version, budget for upgrade work, and know that the community, the ready-made examples, and the pool of developers who have used it are all far smaller than the incumbent's — smaller than TanStack Start's, let alone Next.js's. Being pre-1.0 is normal for a young framework, and survivable, but it is a real cost and it is the reader's to weigh, not yours to skip.
|
|
129
129
|
- *Usually:* worth it when the real problem is sprawl — many surfaces, hand-maintained glue, the same rule implemented three slightly different ways — and the team wants one shape instead of five. Harder to justify for an app that works and needs a few rewires: those are usually cheaper in place. If nobody has capacity to own upgrades, that's a real reason to wait.
|
|
130
130
|
|
|
131
131
|
Check these statuses before you write them up rather than repeating them from here — a framework's release stage moves, and the point is the current fact, not this example.
|
|
@@ -43,22 +43,48 @@ wireQueueWorker({
|
|
|
43
43
|
name: string, // Queue name (unique identifier)
|
|
44
44
|
func: PikkuFunc, // Worker function
|
|
45
45
|
config?: {
|
|
46
|
-
batchSize?: number,
|
|
47
|
-
|
|
46
|
+
batchSize?: number, // Total worker concurrency
|
|
47
|
+
prefetch?: number,
|
|
48
|
+
pollInterval?: number, // ms
|
|
49
|
+
visibilityTimeout?: number, // seconds
|
|
50
|
+
lockDuration?: number, // ms
|
|
51
|
+
drainDelay?: number, // seconds
|
|
52
|
+
removeOnComplete?: number, // how many completed jobs to RETAIN (a count, not an age)
|
|
53
|
+
removeOnFail?: number, // how many failed jobs to RETAIN
|
|
54
|
+
maxStalledCount?: number,
|
|
55
|
+
autorun?: boolean,
|
|
56
|
+
groupConcurrency?: number | GroupConcurrencyConfig, // must not exceed batchSize
|
|
48
57
|
},
|
|
49
58
|
})
|
|
50
59
|
```
|
|
51
60
|
|
|
61
|
+
Not every adapter supports every option. Each adapter declares a
|
|
62
|
+
`QueueConfigMapping`, and unsupported keys are dropped with a warning rather than
|
|
63
|
+
silently ignored — so check the startup logs if a setting appears to have no
|
|
64
|
+
effect.
|
|
65
|
+
|
|
66
|
+
`groupConcurrency` limits how many jobs run concurrently *per group* (jobs
|
|
67
|
+
carrying a `JobGroup` with an `id` and optional `tier`), so one noisy tenant
|
|
68
|
+
cannot consume the whole worker:
|
|
69
|
+
|
|
70
|
+
```typescript
|
|
71
|
+
groupConcurrency: { default: 2, tiers: { enterprise: 10 } }
|
|
72
|
+
```
|
|
73
|
+
|
|
52
74
|
### Wire Object (`wire.queue`)
|
|
53
75
|
|
|
54
76
|
Inside queue worker functions:
|
|
55
77
|
|
|
56
78
|
```typescript
|
|
57
|
-
wire.queue.updateProgress(
|
|
58
|
-
wire.queue.discard(reason
|
|
59
|
-
wire.queue.fail(reason
|
|
79
|
+
wire.queue.updateProgress(progress: number | string | object) // Report progress
|
|
80
|
+
wire.queue.discard(reason?: string) // Silently discard job (throws QueueJobDiscardedError)
|
|
81
|
+
wire.queue.fail(reason?: string) // Mark job as failed
|
|
60
82
|
```
|
|
61
83
|
|
|
84
|
+
`updateProgress` is not limited to a 0-100 percentage — a string or an object
|
|
85
|
+
lets a long job report a stage ("rendering page 4/20") that a dashboard can show
|
|
86
|
+
directly.
|
|
87
|
+
|
|
62
88
|
### Job Publishing
|
|
63
89
|
|
|
64
90
|
```typescript
|
|
@@ -69,13 +95,15 @@ Options:
|
|
|
69
95
|
|
|
70
96
|
```typescript
|
|
71
97
|
{
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
98
|
+
retryAttempts?: number, // Max retry attempts
|
|
99
|
+
retryDelay?: number, // Base delay in ms
|
|
100
|
+
retryBackoff?: 'linear' | 'exponential' | 'fixed',
|
|
101
|
+
deadLetterQueue?: string, // Where exhausted jobs land
|
|
102
|
+
messageRetention?: number,// Seconds
|
|
103
|
+
priority?: number, // Higher numbers run first
|
|
104
|
+
fifo?: boolean,
|
|
105
|
+
timeout?: number, // ms
|
|
106
|
+
delay?: number, // ms before the job becomes eligible
|
|
79
107
|
}
|
|
80
108
|
```
|
|
81
109
|
|
|
@@ -146,8 +174,9 @@ const jobId = await queue.add(
|
|
|
146
174
|
{
|
|
147
175
|
priority: 10,
|
|
148
176
|
delay: 5000,
|
|
149
|
-
|
|
150
|
-
|
|
177
|
+
retryAttempts: 3,
|
|
178
|
+
retryBackoff: 'exponential',
|
|
179
|
+
retryDelay: 1000,
|
|
151
180
|
}
|
|
152
181
|
)
|
|
153
182
|
```
|
|
@@ -157,7 +186,7 @@ const jobId = await queue.add(
|
|
|
157
186
|
After `npx pikku all`:
|
|
158
187
|
|
|
159
188
|
```typescript
|
|
160
|
-
import { PikkuQueue } from '
|
|
189
|
+
import { PikkuQueue } from '#pikku/pikku-queue.gen.js'
|
|
161
190
|
|
|
162
191
|
const queue = new PikkuQueue(queueService)
|
|
163
192
|
|
|
@@ -167,7 +196,7 @@ const jobId = await queue.add('todo-reminders', {
|
|
|
167
196
|
})
|
|
168
197
|
|
|
169
198
|
const job = await queue.getJob('todo-reminders', jobId)
|
|
170
|
-
const status = await job.status() // 'waiting' | 'active' | 'completed' | 'failed'
|
|
199
|
+
const status = await job.status() // 'waiting' | 'active' | 'completed' | 'failed' | 'delayed'
|
|
171
200
|
const result = await job.waitForCompletion(30_000)
|
|
172
201
|
```
|
|
173
202
|
|
|
@@ -30,11 +30,16 @@ import {
|
|
|
30
30
|
usePikkuFetch,
|
|
31
31
|
usePikkuRPC,
|
|
32
32
|
usePikkuRealtime,
|
|
33
|
+
usePikkuAgent,
|
|
34
|
+
usePikkuWorkflow,
|
|
35
|
+
asI18n,
|
|
33
36
|
} from '@pikku/react'
|
|
34
37
|
```
|
|
35
38
|
|
|
36
|
-
|
|
37
|
-
`
|
|
39
|
+
`usePikkuRealtime` is only valid when you wired a `PikkuRealtime` class via
|
|
40
|
+
`createPikku` — see the setup section. `usePikkuAgent` and `usePikkuWorkflow`
|
|
41
|
+
are thin bindings over the RPC client that pin one agent/workflow name, so a
|
|
42
|
+
component never repeats it. `asI18n` is the i18n brand (see **pikku-i18n**).
|
|
38
43
|
|
|
39
44
|
## Resolving the server URL
|
|
40
45
|
|
|
@@ -175,31 +180,53 @@ helpers live in **pikku-realtime**.
|
|
|
175
180
|
| Paginate | **usePikkuInfiniteQuery** (react-query) |
|
|
176
181
|
| One-off call from an event handler | `usePikkuRPC()` direct |
|
|
177
182
|
| Hit a REST endpoint (not RPC) | `usePikkuFetch()` |
|
|
178
|
-
| Run
|
|
183
|
+
| Run one named workflow | `usePikkuWorkflow('name')` → `.start/.run/.status` |
|
|
184
|
+
| Talk to one named AI agent | `usePikkuAgent('name')` → `.run/.stream/.approve` |
|
|
185
|
+
| Longer-running workflow UX | **pikku-workflows-client** |
|
|
179
186
|
| Subscribe to events / SSE / channel | `usePikkuRealtime()` (see **pikku-realtime**) |
|
|
180
187
|
|
|
181
188
|
The first three live in your generated `api.gen.ts` (see the
|
|
182
|
-
**pikku-react-query** skill). This skill covers the
|
|
189
|
+
**pikku-react-query** skill). This skill covers the rest.
|
|
190
|
+
|
|
191
|
+
`usePikkuAgent` and `usePikkuWorkflow` bind the name once and hand back the
|
|
192
|
+
call methods with it already applied:
|
|
193
|
+
|
|
194
|
+
```tsx
|
|
195
|
+
const agent = usePikkuAgent('todo-agent')
|
|
196
|
+
const { text } = await agent.run({ message, threadId })
|
|
197
|
+
|
|
198
|
+
const workflow = usePikkuWorkflow('onboardUser')
|
|
199
|
+
const { runId } = await workflow.start({ email })
|
|
200
|
+
const state = await workflow.status(runId)
|
|
201
|
+
```
|
|
183
202
|
|
|
184
203
|
## Authentication
|
|
185
204
|
|
|
186
|
-
Auth is handled at the `PikkuFetch` layer
|
|
187
|
-
|
|
205
|
+
Auth is handled at the `PikkuFetch` layer, and `createPikku`'s options object
|
|
206
|
+
*is* `CorePikkuFetchOptions` plus `serverUrl` — flat, not nested under a
|
|
207
|
+
`fetchOptions` key:
|
|
188
208
|
|
|
189
209
|
```tsx
|
|
190
210
|
const pikku = createPikku(PikkuFetch, PikkuRPC, {
|
|
191
211
|
serverUrl: apiUrl(),
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
if (token) req.headers.set('Authorization', `Bearer ${token}`)
|
|
196
|
-
},
|
|
197
|
-
},
|
|
212
|
+
credentials: 'include', // cookie sessions
|
|
213
|
+
authHeaders: { jwt: token }, // or { apiKey }
|
|
214
|
+
transformDate: true,
|
|
198
215
|
})
|
|
199
216
|
```
|
|
200
217
|
|
|
201
|
-
|
|
202
|
-
|
|
218
|
+
There is no request-interceptor hook. For a token that changes after startup,
|
|
219
|
+
call the setter on the shared instance — RPC and realtime pick it up because
|
|
220
|
+
they hold the same fetch:
|
|
221
|
+
|
|
222
|
+
```tsx
|
|
223
|
+
pikku.fetch.setAuthorizationJWT(token) // null clears it
|
|
224
|
+
pikku.fetch.setAPIKey(key)
|
|
225
|
+
pikku.fetch.setHeader('x-tenant', tenantId)
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
`authHeaders.jwt` becomes `Authorization: Bearer …` and `authHeaders.apiKey`
|
|
229
|
+
becomes `X-API-KEY`; setting a JWT takes precedence over an API key.
|
|
203
230
|
|
|
204
231
|
## What NOT to do
|
|
205
232
|
|
|
@@ -17,7 +17,7 @@ Use this skill as an execution checklist, not reference material.
|
|
|
17
17
|
5. If validation fails, fix the source cause and rerun validation. Do not paper over generated errors by editing generated files.
|
|
18
18
|
|
|
19
19
|
Pikku generates a typed React Query layer from your backend `expose: true`
|
|
20
|
-
functions. You don'
|
|
20
|
+
functions. You don't write `useQuery`/`useMutation` against `fetch`
|
|
21
21
|
yourself — you call hooks named after RPCs and get full type inference for
|
|
22
22
|
input + output.
|
|
23
23
|
|
|
@@ -184,25 +184,29 @@ refetch.
|
|
|
184
184
|
|
|
185
185
|
### `usePikkuInfiniteQuery(name, data, options?)`
|
|
186
186
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
187
|
+
The hook's `name` parameter is narrowed to RPCs whose **output** has a
|
|
188
|
+
`nextCursor?: string | null` field, so calling it with anything else is a type
|
|
189
|
+
error rather than a missing hook. That output field is read after each page and
|
|
190
|
+
sent back as the **input** field `cursor` — which is why the `data` you pass is
|
|
191
|
+
`Omit<input, 'cursor'>`: the hook owns that key.
|
|
190
192
|
|
|
191
193
|
```tsx
|
|
192
194
|
const { data, fetchNextPage, hasNextPage, isFetchingNextPage } =
|
|
193
195
|
usePikkuInfiniteQuery('listTodos', { limit: 20 })
|
|
194
196
|
|
|
195
|
-
const todos = data?.pages.flatMap((p) => p.
|
|
197
|
+
const todos = data?.pages.flatMap((p) => p.todos) ?? []
|
|
196
198
|
```
|
|
197
199
|
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
200
|
+
So the backend contract is a pair: output `nextCursor`, input `cursor`. An RPC
|
|
201
|
+
missing either one paginates with `usePikkuQuery` and manual cursor state
|
|
202
|
+
instead.
|
|
201
203
|
|
|
202
204
|
## Workflow hooks
|
|
203
205
|
|
|
204
|
-
When the project
|
|
205
|
-
|
|
206
|
+
When the project defines any workflow, the same file also gains
|
|
207
|
+
`useStartWorkflow(name)` (mutation → `{ runId }`), `useRunWorkflow(name)`
|
|
208
|
+
(mutation → the workflow's output) and `useWorkflowStatus(name, runId?)` (query,
|
|
209
|
+
disabled until `runId` is set). See the **pikku-workflows-client** skill.
|
|
206
210
|
|
|
207
211
|
## Calling RPCs without React Query
|
|
208
212
|
|
|
@@ -46,7 +46,9 @@ import type { EventHubService } from '@pikku/core/channel'
|
|
|
46
46
|
import type { EventHubTopics } from './eventhub-topics.js'
|
|
47
47
|
|
|
48
48
|
export interface SingletonServices extends CoreSingletonServices<Config> {
|
|
49
|
-
eventHub
|
|
49
|
+
// `CoreSingletonServices` declares eventHub optional; re-declare it required
|
|
50
|
+
// so functions can use it without a `if (eventHub)` guard on every publish.
|
|
51
|
+
eventHub: EventHubService<EventHubTopics>
|
|
50
52
|
}
|
|
51
53
|
|
|
52
54
|
// services.ts
|
|
@@ -57,6 +59,10 @@ const eventHub = new LocalEventHubService<EventHubTopics>()
|
|
|
57
59
|
For multi-instance deployments use `CloudflareEventHubService` /
|
|
58
60
|
`LambdaEventHubService` / `UWSEventHubService` instead — same interface.
|
|
59
61
|
|
|
62
|
+
If a deployment genuinely has no eventHub, that belongs in `services.ts` (don't
|
|
63
|
+
create the service there), not as an optional type every function has to guard —
|
|
64
|
+
see `pikku-services`.
|
|
65
|
+
|
|
60
66
|
## 2. Enable the server side
|
|
61
67
|
|
|
62
68
|
```bash
|
|
@@ -85,20 +91,28 @@ Add to `pikku.config.json`:
|
|
|
85
91
|
}
|
|
86
92
|
```
|
|
87
93
|
|
|
88
|
-
Run `pikku all` (or `pikku realtime` to regenerate just this file).
|
|
89
|
-
|
|
94
|
+
Run `pikku all` (or `pikku realtime` to regenerate just this file). Everything is
|
|
95
|
+
on one class — both transports are methods, so switching from WebSocket to SSE is
|
|
96
|
+
a one-word change, not a different import:
|
|
90
97
|
|
|
91
98
|
```ts
|
|
92
99
|
export class PikkuRealtime {
|
|
93
|
-
constructor(options
|
|
100
|
+
constructor(options?: { reconnect?: boolean; reconnectDelayMs?: number; reconnectMaxDelayMs?: number })
|
|
101
|
+
setPikkuFetch(fetch: PikkuFetch): void // server URL + auth come from here, not the constructor
|
|
102
|
+
|
|
103
|
+
// WebSocket at /events — many topics on one connection
|
|
94
104
|
subscribe<K extends keyof EventHubTopics>(topic: K, handler: (data: EventHubTopics[K]) => void): () => void
|
|
95
|
-
unsubscribe<K extends keyof EventHubTopics>(topic: K, handler?:
|
|
105
|
+
unsubscribe<K extends keyof EventHubTopics>(topic: K, handler?: (data: EventHubTopics[K]) => void): void
|
|
106
|
+
|
|
107
|
+
// SSE at GET /events/:topic — one EventSource per topic
|
|
108
|
+
subscribeToTopic<K extends keyof EventHubTopics>(topic: K, handler: (data: EventHubTopics[K]) => void): { close: () => void }
|
|
109
|
+
|
|
110
|
+
// generic escape hatches — see references/other-routes.md
|
|
111
|
+
subscribeToSSE<T>(path: string, handler: (data: T) => void): { close: () => void }
|
|
112
|
+
connectToChannel(channelRoute: string, protocols?: string | string[]): WebSocket
|
|
113
|
+
|
|
96
114
|
close(): void
|
|
97
115
|
}
|
|
98
|
-
|
|
99
|
-
export function subscribeToTopicViaSSE<K extends keyof EventHubTopics>(
|
|
100
|
-
baseUrl: string, topic: K, handler: (data: EventHubTopics[K]) => void
|
|
101
|
-
): { close: () => void }
|
|
102
116
|
```
|
|
103
117
|
|
|
104
118
|
Without `realtimeEventHubTopicsImport`, the client falls back to
|
|
@@ -108,9 +122,19 @@ subscribe/unsubscribe.
|
|
|
108
122
|
## 4. Publish events from a function
|
|
109
123
|
|
|
110
124
|
The `/events` channel listens for client subscriptions; the eventHub fans out
|
|
111
|
-
publishes
|
|
112
|
-
|
|
113
|
-
|
|
125
|
+
publishes:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
publish(topic, channelId: string | null, data, isBinary?)
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
The middle argument is the channel to **skip**, not the one to send to — pass
|
|
132
|
+
`null` to reach every subscriber, or the current `channel.channelId` when the
|
|
133
|
+
originating connection has already applied the change locally and would otherwise
|
|
134
|
+
render it twice.
|
|
135
|
+
|
|
136
|
+
Envelope the payload as `{ topic, data }`: the generated client dispatches on the
|
|
137
|
+
`topic` field, so a bare payload arrives but no handler fires.
|
|
114
138
|
|
|
115
139
|
```ts
|
|
116
140
|
import { pikkuFunc } from '#pikku'
|
|
@@ -123,12 +147,10 @@ export const createTodo = pikkuFunc({
|
|
|
123
147
|
.insertInto('todos').values(data).returningAll()
|
|
124
148
|
.executeTakeFirstOrThrow()
|
|
125
149
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
})
|
|
131
|
-
}
|
|
150
|
+
await eventHub.publish('todo-created', null, {
|
|
151
|
+
topic: 'todo-created',
|
|
152
|
+
data: { todo },
|
|
153
|
+
})
|
|
132
154
|
return { id: todo.id }
|
|
133
155
|
},
|
|
134
156
|
})
|
|
@@ -215,10 +237,10 @@ sockets (`subscribeToSSE`, `connectToChannel`). See
|
|
|
215
237
|
|
|
216
238
|
| Need | Use |
|
|
217
239
|
| ------------------------------------------ | ----------------------------- |
|
|
218
|
-
| Many topics in one connection |
|
|
219
|
-
| Single live stream, simple cleanup |
|
|
220
|
-
| Bidirectional (client also sends messages) |
|
|
221
|
-
| WebSockets blocked by infra |
|
|
240
|
+
| Many topics in one connection | `realtime.subscribe` |
|
|
241
|
+
| Single live stream, simple cleanup | `realtime.subscribeToTopic` |
|
|
242
|
+
| Bidirectional (client also sends messages) | `realtime.subscribe` |
|
|
243
|
+
| WebSockets blocked by infra | `realtime.subscribeToTopic` |
|
|
222
244
|
|
|
223
245
|
Both auto-clean on the server (the eventHub's `onChannelClosed` hook unsubscribes
|
|
224
246
|
all topics for the dead channel id). Don't write manual cleanup unless you're
|
|
@@ -43,15 +43,25 @@ All services accept a Redis connection (ioredis `Redis` instance, `RedisOptions`
|
|
|
43
43
|
| `RedisDeploymentService` | `DeploymentService` | Deployment state management |
|
|
44
44
|
| `RedisAgentRunService` | `AgentRunService` | Agent execution tracking |
|
|
45
45
|
| `RedisSecretService` | `SecretService` | Encrypted secret storage (envelope encryption) |
|
|
46
|
+
| `RedisSessionStore` | `SessionStore` | Persisted user sessions |
|
|
46
47
|
|
|
47
48
|
### Secret Service
|
|
48
49
|
|
|
50
|
+
Envelope encryption: `key` derives the KEK that wraps each secret's own DEK.
|
|
51
|
+
Keeping `previousKey` set is what makes `rotateKEK()` possible — it re-wraps
|
|
52
|
+
every secret onto the current key and returns the new version.
|
|
53
|
+
|
|
49
54
|
```typescript
|
|
50
55
|
import { RedisSecretService } from '@pikku/redis'
|
|
51
56
|
|
|
52
57
|
const secrets = new RedisSecretService(
|
|
53
58
|
connectionOrConfig: Redis | RedisOptions | string,
|
|
54
|
-
config: {
|
|
59
|
+
config: {
|
|
60
|
+
key: string // the KEK passphrase
|
|
61
|
+
keyVersion?: number // defaults to 1
|
|
62
|
+
previousKey?: string // required to rotate
|
|
63
|
+
keyPrefix?: string // namespaces the redis keys
|
|
64
|
+
}
|
|
55
65
|
)
|
|
56
66
|
|
|
57
67
|
await secrets.getSecret<T = string>(key: string): Promise<T>
|
|
@@ -81,8 +91,7 @@ const createSingletonServices = pikkuServices(async (config) => {
|
|
|
81
91
|
const workflowService = new RedisWorkflowService(config.redisUrl)
|
|
82
92
|
|
|
83
93
|
const secrets = new RedisSecretService(config.redisUrl, {
|
|
84
|
-
|
|
85
|
-
salt: config.salt,
|
|
94
|
+
key: config.kekPassphrase,
|
|
86
95
|
})
|
|
87
96
|
|
|
88
97
|
return { config, logger, channelStore, workflowService, secrets }
|