@pikku/skills 0.12.25 → 0.12.26

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pikku/skills",
3
- "version": "0.12.25",
3
+ "version": "0.12.26",
4
4
  "description": "The Pikku agent skills — the instruction set coding agents read to build, wire and deploy Pikku projects",
5
5
  "author": "yasser.fadl@gmail.com",
6
6
  "license": "MIT",
@@ -0,0 +1,59 @@
1
+ ---
2
+ name: pikku-a11y
3
+ description: >-
4
+ Accessibility rules (WCAG 2.2) for the app UI: labeled inputs, real buttons/links, keyboard and focus, contrast and not-color-alone, modals, reduced motion.
5
+ TRIGGER when: building forms or any interactive UI, icon-only buttons, modals/drawers, tables/lists with actions, keyboard/focus work, or the user mentions accessibility / screen readers / WCAG.
6
+ DO NOT TRIGGER when: working on backend functions, database, or deployment with no UI.
7
+ installGroups: [client]
8
+ ---
9
+
10
+ # Accessibility Rules
11
+
12
+ Mantine components are accessible ONLY when used properly — the rules below are the
13
+ "properly". They apply to every page; heading order, landmarks, and image alt text are
14
+ covered in the `pikku-seo` skill and apply app-wide, not just on public pages.
15
+
16
+ ## Every input has a label
17
+
18
+ - Use the `label` prop on every Mantine input — a placeholder is NOT a label (it
19
+ disappears on input and is never announced as one). Placeholder = example value only.
20
+ - Use the `error` and `description` props for validation/help text — Mantine associates
21
+ them with the input for screen readers; a loose `<Text c="red">` next to the field
22
+ does not.
23
+ - Icon-only controls (`ActionIcon`, icon `Button`) MUST have `aria-label={m.key()}`
24
+ naming the action ("Delete item", not "Trash icon").
25
+
26
+ ## Interactive = a real button or link
27
+
28
+ - Never `onClick` on a `div`/`Box`/`Card` — it is invisible to keyboard and screen
29
+ readers. Use `Button`, `ActionIcon`, `UnstyledButton`, or `<Link>`; navigation is a
30
+ link (href), actions are buttons.
31
+ - Everything reachable by Tab, activatable by Enter/Space. Never remove focus outlines
32
+ (the theme owns the focus ring), never set `tabIndex` greater than 0, never trap focus
33
+ yourself.
34
+ - Whole-row/whole-card click: put the button/link INSIDE with the row as its label —
35
+ don't make the container clickable and unfocusable.
36
+
37
+ ## Don't say it with color alone
38
+
39
+ - Status must carry text or an icon, not only a color: a Badge says "Overdue", a form
40
+ error has a message — a red tint by itself is invisible to colorblind users.
41
+ - Contrast comes from the theme; don't undermine it by stacking `c="dimmed"` on small
42
+ text over tinted backgrounds. Body copy stays at least AA-readable.
43
+ - Touch targets: WCAG 2.2 minimum 24px — don't shrink `ActionIcon`/`Checkbox` below
44
+ size `sm`, and keep adjacent row actions spaced.
45
+
46
+ ## Overlays and motion
47
+
48
+ - Modals/drawers: use Mantine `Modal`/`Drawer` and ALWAYS pass `title` — that is what
49
+ gets announced; focus trap and Escape come built in. (This project uses drawers, not
50
+ dialogs.)
51
+ - Landing-page animation (the only custom-CSS surface) respects
52
+ `prefers-reduced-motion: reduce` — gate transforms/parallax behind the media query.
53
+
54
+ ## Self-check before declaring UI done
55
+
56
+ Tab through the page once: every control reachable and visibly focused, every input
57
+ labeled, every icon button named, every status readable without color. A browser
58
+ scenario proves the flow works, not that it is reachable without a mouse — this
59
+ manual pass is the only check that does.
@@ -11,6 +11,7 @@ description: >-
11
11
  NOT TRIGGER when: the milestone notes themselves are still being written (use pikku-knowledge),
12
12
  the plan already exists and the job is to build it (use pikku-build), or the ask is a one-off
13
13
  edit to a working app.
14
+ installGroups: [core]
14
15
  ---
15
16
 
16
17
  # Plan one milestone
@@ -15,7 +15,6 @@ The only acceptable auth implementation in a Pikku app is the one described in t
15
15
 
16
16
  ---
17
17
 
18
-
19
18
  ## Installation
20
19
 
21
20
  ```bash
@@ -448,9 +447,14 @@ someone" means **a particular kind of user** rather than one fixed admin.
448
447
  Register it explicitly — it is not automatic:
449
448
 
450
449
  ```typescript
451
- import { pikkuActor } from '@pikku/better-auth'
452
-
453
- plugins: [pikkuActor({ secret: SCENARIO_ACTOR_SECRET })]
450
+ import { ACTOR_SIGN_IN_OPT_IN_ENV, pikkuActor } from '@pikku/better-auth'
451
+
452
+ plugins: [
453
+ pikkuActor({
454
+ secret: SCENARIO_ACTOR_SECRET,
455
+ allowSignIn: await variables.get(ACTOR_SIGN_IN_OPT_IN_ENV),
456
+ }),
457
+ ]
454
458
  ```
455
459
 
456
460
  `POST ${basePath}/sign-in/actor` `{ email, secret, name? }` → 200 + the normal
@@ -481,9 +485,19 @@ itself instead.
481
485
  A stage that genuinely must run scenarios opts in on purpose, with
482
486
  `PIKKU_ALLOW_ACTOR_SIGN_IN=passwordless-actor-sign-in`. Any other value is
483
487
  ignored and warned about, so the hatch cannot be opened by copying a `true` from
484
- the line above, and it is the only hatch — there is no build-time option, because
485
- an option compiled into the bundle cannot be audited from the environment it
486
- runs in.
488
+ the line above.
489
+
490
+ **Pass it in on any runtime without a populated `process.env`.** The gate reads
491
+ the environment by default, which is enough for Node but not for a Worker: there
492
+ the opt-in arrives as a binding and reaches user code through the variables
493
+ service, so a gate left to `process.env` stays shut on exactly the stages a
494
+ deployment targets. `allowSignIn` takes the value the caller already read —
495
+ `await variables.get(ACTOR_SIGN_IN_OPT_IN_ENV)` — and is checked against the same
496
+ literal, near-miss warning included. It is deliberately a value and not a flag:
497
+ what opens the gate is still something the deployment set and an operator can
498
+ read back out of it, never something compiled into the bundle. A value passed
499
+ here is the one consulted, so the environment cannot quietly override what the
500
+ stage was configured with.
487
501
 
488
502
  **Signing in and provisioning are separate powers.** An unknown address becomes
489
503
  an `actor: true` row only under `pikku dev`. With the opt-in set, a stage signs
@@ -624,3 +638,107 @@ export const auth = pikkuBetterAuth(async ({ secrets, variables, kysely }) => be
624
638
  - Export exactly ONE `pikkuBetterAuth` per project; the CLI generates a single catch-all worker for all auth routes.
625
639
  - `betterAuthSession({ auth })` (generated) bridges the better-auth session into the Pikku session on every request — you never add it by hand.
626
640
  - MFA, organizations, passkeys, etc. are better-auth plugins: add them to `betterAuth({ plugins: [...] })`. The catch-all route already forwards their endpoints.
641
+
642
+ ---
643
+
644
+ ## Post-signup side effects
645
+
646
+ Anything that must happen after a user signs up — a welcome email, seeding a first
647
+ row, creating a personal organization — goes in `databaseHooks.user.create.after`
648
+ inside the `betterAuth({...})` config. Never a custom signup RPC that writes the
649
+ user itself: better-auth owns the `user` table, and a second write path desyncs it
650
+ from the session bridge.
651
+
652
+ ## Two-factor (2FA / MFA)
653
+
654
+ TOTP authenticator apps, email/SMS OTP, backup codes and trusted devices are all
655
+ better-auth's `twoFactor()` plugin. Never hand-roll TOTP or OTP.
656
+
657
+ 1. **Enable + migrate** — add `twoFactor({ issuer: '<AppName>' })` to the `plugins`
658
+ array in the `pikkuBetterAuth` factory, then generate its schema and apply it as a
659
+ migration like any other table change. Run the CLI at the version of better-auth the
660
+ project actually has installed (`npx @better-auth/cli@<that version> generate`) — a
661
+ bare `npx @better-auth/cli` resolves the latest release, which can emit a schema for
662
+ a library you are not running. `twoFactorSecret` lands on `user`.
663
+ 2. **Client** — add `twoFactorClient({ onTwoFactorRedirect() { /* go to /2fa */ } })`
664
+ to `createAuthClient({ plugins: [...] })`, and expose thin wrappers
665
+ (`enable2FA`, `verifyTotp`, `disable2FA`, …) rather than leaking the raw
666
+ `authClient`, same as every other auth call.
667
+ 3. **OTP delivery** — `otpOptions.sendOTP` goes through the injected email service
668
+ and a rendered template, not a raw `sendEmail`. Set `storeOTP: 'encrypted'`.
669
+ 4. **Sign-in flow** — the challenge is raised on the three CREDENTIAL endpoints:
670
+ `signIn.email`, `signIn.username` and `signIn.phoneNumber`. Check
671
+ `context.data.twoFactorRedirect` in `onSuccess`; if true, route to a `/2fa` page
672
+ and verify via `verifyTotp`/`verifyOtp`/`verifyBackupCode` (`trustDevice: true`
673
+ for a 30-day trusted device). The response also carries `twoFactorMethods`
674
+ (`'totp'` only once that user has a verified secret, `'otp'` whenever
675
+ `otpOptions.sendOTP` is configured) — render the choice from it rather than
676
+ assuming TOTP. The session cookie is only created after verification: the
677
+ credential handler's session is deleted while the challenge is in flight, so a
678
+ hook reading `ctx.context.newSession` after sign-in must null-check it.
679
+ 5. **UI** — a QR rendered from `data.totpURI` plus the `data.backupCodes` list.
680
+ Enabling, disabling and regenerating backup codes all require the user's
681
+ password.
682
+
683
+ TOTP secrets and backup codes are encrypted at rest with the auth secret, and
684
+ `/two-factor/*` is rate-limited (3/10s) out of the box.
685
+
686
+ **2FA gates credential sign-in only.** Magic link, email OTP and OAuth are not
687
+ matched by the plugin's hook, so a user with 2FA enabled who signs in through one
688
+ of them is NOT challenged. If every route into the app must be gated, either do not
689
+ offer the passwordless ones to 2FA users or add your own check — enabling the
690
+ plugin does not do it.
691
+
692
+ ## Security hardening
693
+
694
+ The `pikkuBetterAuth` factory — where `betterAuth({...})` is built — is the one
695
+ place to harden. Everything below is a `betterAuth` option, not a pikku one.
696
+
697
+ - **Secret** — `BETTER_AUTH_SECRET` comes from the injected secrets service
698
+ (`await secrets.getSecret('BETTER_AUTH_SECRET')`), never `process.env`, a
699
+ literal, or a fallback default. 32+ chars, high entropy
700
+ (`openssl rand -base64 32`). Better Auth rejects placeholder secrets in
701
+ production.
702
+ - **Trusted origins** — the `baseURL` origin is auto-trusted, so a single-domain
703
+ app serving its API same-origin needs nothing. Add `trustedOrigins` (or a
704
+ comma-separated `BETTER_AUTH_TRUSTED_ORIGINS` variable; wildcards like
705
+ `*.example.com` allowed) ONLY when the browser origin differs from the API
706
+ origin — embedded, preview, or custom-domain deployments. An untrusted
707
+ `callbackURL`/`redirectTo`/`origin` is a 403.
708
+ - **CSRF** — keep it on (`advanced.disableCSRFCheck: false`, the default). A
709
+ proxy in front of the app must preserve the `/api/auth/*` prefix so origin
710
+ checks still work; do not disable the check to "fix" a redirect.
711
+ - **Rate limiting** — on by default in production (100/10s global, 3/10s on
712
+ sign-in/up/change-password). `storage: 'memory'` resets on restart, so a
713
+ deployed app wants `storage: 'database'`. Tighten sensitive routes with
714
+ `customRules`, e.g. `'/sign-in/email': { window: 60, max: 5 }` — the key is matched
715
+ against the path with the base path ALREADY STRIPPED, so a rule written as
716
+ `'/api/auth/sign-in/email'` matches nothing and silently leaves the route on the
717
+ default.
718
+ - **Cookies & sessions** — `httpOnly`, `sameSite: 'lax'` and `path: '/'` are
719
+ unconditional, but `secure` and the `__Secure-` name prefix are NOT: they follow a
720
+ `baseURL` on `https://` (or production, or an explicit
721
+ `advanced.useSecureCookies: true`). A deployment whose TLS terminates at a proxy
722
+ and passes an `http://` baseURL through therefore ships session cookies with no
723
+ `secure` flag — set `useSecureCookies` there rather than assuming. Defaults are
724
+ `session.expiresIn` 7d and
725
+ `updateAge` 1d. Add `freshAge` for sensitive actions, and
726
+ `cookieCache: { strategy: 'jwe' }` if the session carries sensitive data — see
727
+ the cookieCache section above, which you want enabled regardless. Only enable
728
+ `crossSubDomainCookies` if auth is genuinely shared across subdomains.
729
+ - **OAuth tokens** — set `account.encryptOAuthTokens: true` (AES-256-GCM) if you
730
+ store provider tokens to call their APIs later.
731
+ - **Audit** — drive auth events from `databaseHooks` (`session.create.after`,
732
+ `user.update.after` for email changes, `account.create.after` for links) into
733
+ whatever audit service the app injects, never a bespoke audit table wired into
734
+ a function body. Returning `false` from a `before` hook blocks the operation.
735
+ - **Background tasks** — on a serverless target, hand genuinely disposable work
736
+ (analytics, logging) to `advanced.backgroundTasks.handler` → `ctx.waitUntil(promise)`
737
+ so it does not delay the response. Not mail: the default handler is
738
+ `p.catch(() => {})`, so anything that must actually arrive — an invitation, a
739
+ password reset — is lost without a trace if the platform reaps the request first.
740
+ Better Auth sends its own through `runInBackgroundOrAwait`, which awaits when no
741
+ handler is configured; do the same for yours.
742
+ - **Enumeration** — handled already (generic "Invalid credentials", dummy work on
743
+ unknown users). Keep your own error copy generic too; never leak "user not
744
+ found".
@@ -12,6 +12,7 @@ description: >-
12
12
  or wants one specific surface explained rather than built (use that surface's skill).
13
13
  allowed-tools: Bash(yarn pikku meta *), Bash(yarn pikku all *), Bash(yarn tsc), Bash(git status *), Bash(git diff *), Bash(git switch *), Bash(git checkout *), Bash(git checkout -b *), Bash(git add *), Bash(git commit *), Bash(git rm *), Bash(git mv *), Bash(git log *), Bash(git branch *), Bash(yarn pikku fabric report *), Bash(npx --no pikku fabric report *)
14
14
  argument-hint: '[feature description]'
15
+ installGroups: [core]
15
16
  ---
16
17
 
17
18
  # Build on Pikku
@@ -265,7 +265,7 @@ the database still holds that code no longer declares — both need §0's bootst
265
265
  to have run, and both are worth a look once it has.
266
266
 
267
267
  **One warning about the scaffold's own notes:** `knowledge/index.md` may claim
268
- the people live in `pikku.config.json`, put there by a `fabric persona` command.
268
+ the people live in `pikku.config.json`, put there by a persona command.
269
269
  That is stale. In this template they live in `personas.ts` as above, and
270
270
  `pikku.config.json` carries no personas key at all — only `scenarios.emailDomain`.
271
271
  Trust the file you can read over the note describing it.
@@ -8,6 +8,61 @@ leaves `pikkufabric.config.json` pointing at an app nobody has designed yet.
8
8
  If the split is "one app with paths", you never need this file — add route
9
9
  segments under `/app` and give each audience its own entries in `useNavItems()`.
10
10
 
11
+ ## Deciding it is two apps, not one
12
+
13
+ The split you are acting on should already be recorded, but this is the reasoning
14
+ behind it — and the place people get it wrong is the third case at the bottom.
15
+
16
+ **A group that comes in through its own front door gets its own app.** A role
17
+ *inside* an app is not that: it changes which nav items and which buttons a person
18
+ sees, and lives in `useNavItems()` and the `permissions` on the function, not in a
19
+ route subtree.
20
+
21
+ **The test is which side of the counter they are on.** Colleagues share one app and
22
+ differ by nav — the mechanic, the person on the counter, the bookkeeper. Someone
23
+ across the counter with an account of their own gets their own — the customer, the
24
+ tenant, the patient. One app is a real answer and often the right one.
25
+
26
+ **The asymmetry that forces a split is sign-up.** Where staff accounts are created
27
+ *for* people and customers create their own, the two need different sign-up, different
28
+ onboarding and different session shape, and bending one app around both costs more
29
+ than the second app does. Do not collapse two audiences into one app to save a build.
30
+
31
+ Never invent a person the notes do not name in order to reach two.
32
+
33
+ ### The group that never signs in
34
+
35
+ Some people use the product with no account at all — ordering from a menu, booking a
36
+ table, opening an invitation. They are not a third case, and **they do not get their
37
+ own frontend**: an app is built around the personas who sign into it. What they get is
38
+ the public route space every app already has.
39
+
40
+ - **`/app/*` is the signed-in application.** One `beforeLoad` on `/app` bounces a
41
+ signed-out visitor to the login. There is no per-route exception.
42
+ - **Every other route is public** — `/`, `/menu`, `/book`, `/r/$code`. No gate, no
43
+ session.
44
+ - **`/` is a landing page and you must write it.** A starter that forwards `/` to
45
+ `/app` does so only because it ships no homepage. Leave the forward in and the
46
+ product's front door is a sign-in form: the anonymous visitor arrives at a login it
47
+ has no account for and never reaches the thing it came for — **while every check
48
+ still passes**, because everything that looks at the app signs in first. This is the
49
+ failure this section exists for.
50
+
51
+ So a screen whose users have no account goes at `/menu`, never `/app/menu`.
52
+
53
+ ### The frontend guard is UX and proves nothing
54
+
55
+ The bundle is on the origin and the nav is a client-side decision; anyone can read
56
+ both. The security boundary is the `permissions` field on the function — see
57
+ pikku-permissions. Hiding a nav item keeps people out of screens that would confuse
58
+ them; it never protects data. Never let a hidden UI be the only thing between a user
59
+ and someone else's record: if the invoices nav item is hidden but `listAllInvoices`
60
+ has no `permissions`, the app is wide open and the nav is decoration.
61
+
62
+ Worth a scenario each, because they are two different claims: that a mechanic cannot
63
+ *see* the invoices nav item, and that their call to an invoices RPC is *refused*. The
64
+ second is the one that catches a `permissions` field nobody wired.
65
+
11
66
  ## The clone
12
67
 
13
68
  ```bash
@@ -74,8 +74,8 @@ Everything above is open source. This is the contract that keeps
74
74
  - **`pikkufabric.config.json` describes reality.** Every app has an entry with
75
75
  the right `cwd`, `port`, `kind` and `dev.command`; exactly one is `primary`;
76
76
  `serves` and `personas` name real personas from the personas section. Leave `projectId` as
77
- `__PROJECT_ID__` — that placeholder means "unlinked", and `fabric init` writes
78
- the real one. Do not invent a value to make it look configured.
77
+ `__PROJECT_ID__` — that placeholder means "unlinked", and linking the project
78
+ writes the real one. Do not invent a value to make it look configured.
79
79
  - **One `definePersonas` call**, every persona reachable through exactly one
80
80
  frontend. Fabric materialises these as its virtual users; a persona nobody
81
81
  serves imports as a person with no way in.
@@ -291,24 +291,40 @@ reload).
291
291
  pikku fabric login # opens a browser; needs a human, wait for it
292
292
  pikku fabric init https://github.com/<owner>/<repo>
293
293
  pikku fabric validate # must pass clean
294
- pikku fabric deploy apply --production --sync --auto-approve
294
+ pikku fabric deploy apply --production -y
295
295
  ```
296
296
 
297
+ The branch is positional and defaults to the checked-out one, and `-y` is the
298
+ short form of `--auto-approve`, so a one-shot deploy is:
299
+
300
+ ```bash
301
+ pikku fabric deploy apply -y # the branch you are standing on
302
+ pikku fabric deploy apply my-branch -y # a named one
303
+ ```
304
+
305
+ `-y` answers the prompts and nothing more. It does **not** approve migrations
306
+ that drop or rewrite data — that stays `--allow-destructive`, typed out on
307
+ purpose.
308
+
309
+ Inferring the branch is safe because the git safety check refuses any branch
310
+ without an upstream or out of sync with it, so it cannot ship an unpushed
311
+ commit; the branch it picked is printed before the build starts. A detached
312
+ HEAD is refused by name rather than travelling on as a branch called `HEAD`.
313
+
297
314
  There is no `deploy plan` subcommand — `apply` runs the same auth, git-safety
298
315
  and ref resolution itself, and fabric produces the real plan server-side.
299
316
 
300
317
  `apply` confirms before deploying, and with no TTY to ask — CI, an agent shell —
301
- it refuses rather than hangs. `--auto-approve` supplies that confirmation; drop
302
- it only when a human is at a real terminal.
318
+ it refuses rather than hangs. `--auto-approve` (`-y`) supplies that confirmation;
319
+ drop it only when a human is at a real terminal.
303
320
 
304
- By default `apply` queues the deploy, prints the deployment id and returns. That
305
- tells you nothing about whether it worked. `--sync` waits for a terminal state
306
- and exits non-zero unless the deployment went live, which is the only form worth
307
- running in CI:
321
+ `apply` waits for a terminal state and exits non-zero unless the deployment went
322
+ live. `--detach` opts out — it queues the deploy, prints the deployment id and
323
+ returns 0, which tells you nothing about whether it worked:
308
324
 
309
325
  | exit | meaning |
310
326
  | ---- | ----------------------------------------------------------------------- |
311
- | 0 | live (or queued, without `--sync`) |
327
+ | 0 | live (or queued, under `--detach`) |
312
328
  | 1 | the command could not run — not logged in, unsafe git state, bad flags |
313
329
  | 2 | the deployment failed, errored, timed out server-side, or was cancelled |
314
330
  | 3 | the deployment is blocked and nothing the CLI can do will unblock it |
@@ -318,14 +334,14 @@ Fabric parks every deploy at a gate after the plan phase (`status: suspended`).
318
334
  Why it parked is the whole story, and it is `statusReason`, not `status`:
319
335
 
320
336
  - `awaiting_approval` — the plan is fine, a human has to publish it.
321
- `--auto-approve` does that; without it you get exit 3 and the command to run.
337
+ `-y` does that; without it you get exit 3 and the command to run.
322
338
  One exception: if fabric marked any pending migration **destructive** — a
323
- drop, a truncate, a rewrite — `--auto-approve` alone declines and exits 3,
339
+ drop, a truncate, a rewrite — `-y` alone declines and exits 3,
324
340
  because a standing yes was given before anyone knew the plan dropped a table.
325
341
  The CLI lists the migrations and fabric's reasons; `--allow-destructive`
326
- accepts them for that deploy.
342
+ accepts them for that deploy, and `-y` implies it.
327
343
  - `needs_config` — a declared secret or variable has no value covering the
328
- stage. The CLI names them. `--auto-approve` will **not** force this through;
344
+ stage. The CLI names them. `-y` will **not** force this through;
329
345
  set the values and re-attach — `pikku fabric secrets set <name>` for a
330
346
  declared secret, `pikku fabric variables set <name> --value <v>` for a declared
331
347
  variable. They are separate stores: a secret is sealed to the stage and cannot
@@ -334,24 +350,25 @@ Why it parked is the whole story, and it is `statusReason`, not `status`:
334
350
  stage exactly as it is from `.env`, and `--value '"true"'` is the string.
335
351
  - `needs_attention` — the plan is red. Nothing to approve.
336
352
 
337
- `--sync` defaults to a 900s ceiling; `--timeout <seconds>` moves it. On timeout
353
+ The wait defaults to a 900s ceiling; `--timeout <seconds>` moves it. On timeout
338
354
  it prints the deployment id and the re-attach command rather than lying about
339
355
  the outcome.
340
356
 
341
357
  Splitting kick-off from waiting across two CI jobs is the reason
342
- `--deployment-id` exists:
358
+ `--deployment-id` exists, and what `--detach` is for — the first job here has to
359
+ return the id and exit rather than wait:
343
360
 
344
361
  ```bash
345
- id=$(pikku fabric deploy apply --production --auto-approve --json | jq -r 'select(.event=="result").deploymentId')
362
+ id=$(pikku fabric deploy apply --production -y --detach --json | jq -r 'select(.event=="result").deploymentId')
346
363
  # …later, in another job…
347
- pikku fabric deploy apply --deployment-id "$id" --sync --auto-approve
364
+ pikku fabric deploy apply --deployment-id "$id" -y
348
365
  ```
349
366
 
350
367
  `--deployment-id` skips the git safety check entirely (the deployment already
351
368
  pins a sha, and the checkout is allowed to have moved on) and refuses to be
352
- combined with `--branch`/`--production`, which would let the two disagree.
369
+ combined with a branch or `--production`, which would let the two disagree.
353
370
 
354
- Under `--json`, `--sync` emits one NDJSON event per line — `created`/`attached`,
371
+ Under `--json`, the wait emits one NDJSON event per line — `created`/`attached`,
355
372
  `status` on each transition, `blocked`, `approved` — and the last line is the
356
373
  terminal result object, tagged `"event": "result"`.
357
374
 
@@ -63,9 +63,11 @@ is what makes an RTL language just another locale file.
63
63
  - **Do not `asI18n()` a hardcoded English string.** `asI18n` exists to pass
64
64
  opaque server data (a name, a slug, an id) through the i18n gate. An enum value
65
65
  goes through its generated label map.
66
- - **Do not wrap `m`.** No re-export module, no branding layer. `m.some__key()`
67
- already satisfies the `I18nNode` gate; a wrapper adds nothing and costs
68
- per-message tree-shaking.
66
+ - **Do not wrap `m` without a reason you can name.** `m.some__key()` already
67
+ satisfies the `I18nNode` gate, so a plain re-export module adds nothing and
68
+ costs per-message tree-shaking. Wrapping the namespace is only worth it when it
69
+ buys a feature the gate cannot — debug masking of translated copy, say — and
70
+ then the catalogue has to be small enough to ship whole.
69
71
  - **Do not translate message keys.** `auth__login__title` stays English in
70
72
  `de.json`; only the value changes.
71
73
  - **Do not edit or commit `src/paraglide/`, `i18n-enum.gen.ts` or `enums.gen.ts`.**
@@ -13,6 +13,7 @@ description: >-
13
13
  brief to record. DO NOT TRIGGER when: user asks what
14
14
  functions, routes, tables or permissions exist (that is `pikku meta` / `pikku info`, never a
15
15
  note), or to write a scenario test (use pikku-scenario).
16
+ installGroups: [core]
16
17
  ---
17
18
 
18
19
  # Pikku Knowledge
@@ -0,0 +1,163 @@
1
+ ---
2
+ name: pikku-list-query
3
+ description: >-
4
+ Use when building a paginated/infinite-scroll list — any RPC that returns rows a user scrolls through (tables, card grids, search results). Covers pikkuListFunc, the ListInput/ListOutput cursor contract, and the generated usePikkuInfiniteQuery hook.
5
+ TRIGGER when: user asks for infinite scroll, "load more", a paginated table/list/grid, or a list that could grow beyond a single page.
6
+ DO NOT TRIGGER when: the list is small and fixed (e.g. a settings page with 5 items) — a plain pikkuFunc + usePikkuQuery returning a full array is simpler and correct there.
7
+ installGroups: [core, client]
8
+ ---
9
+
10
+ # Pikku List Queries
11
+
12
+ ## Agent Operating Procedure
13
+
14
+ 1. Capture baseline. Run `pikku all` BEFORE writing code; only NEW errors are yours to fix.
15
+ 2. Write the backend function with `pikkuListFunc` (below) — never a bespoke `{items: [...]}` shape once the list can page.
16
+ 3. Run `pikku all` to regenerate `usePikkuInfiniteQuery` for the new function.
17
+ 4. Wire the frontend with `usePikkuInfiniteQuery`, not a hand-rolled `useState` page counter and not a raw `useInfiniteQuery` — the generated hook already resolves cursor plumbing from your function's types.
18
+ 5. Validate with `pikku all`.
19
+
20
+ ## The `pikkuListFunc` contract
21
+
22
+ A list function is a normal `pikkuFunc`/`pikkuSessionlessFunc` whose input/output conform to two shared shapes from `@pikku/core`:
23
+
24
+ ```typescript
25
+ interface ListInput<F extends Record<string, unknown> = {}, S extends string = never> {
26
+ cursor?: string // opaque — echo back whatever you returned as nextCursor
27
+ limit?: number // page size; server may cap it
28
+ sort?: Array<{ column: S; direction: 'asc' | 'desc' }>
29
+ filter?: Filter<F> // structured AND/OR tree, Prisma-style leaf operators
30
+ search?: string // free-text search across server-chosen fields
31
+ }
32
+
33
+ interface ListOutput<Row> {
34
+ rows: Row[]
35
+ nextCursor: string | null // null = no more pages
36
+ totalCount?: number // optional — skip when expensive to compute
37
+ }
38
+ ```
39
+
40
+ Adopting this shape is what makes the function eligible for the generated `usePikkuInfiniteQuery` hook — the react-query codegen structurally detects any RPC whose output includes `nextCursor` and generates an infinite-query hook for it automatically. No manual wiring, no opt-in flag.
41
+
42
+ ```typescript
43
+ import { pikkuListFunc } from '#pikku/function'
44
+
45
+ interface Item {
46
+ id: string
47
+ label: string
48
+ }
49
+
50
+ export const listItems = pikkuListFunc<{ status?: string }, Item>({
51
+ expose: true,
52
+ auth: true,
53
+ readonly: true,
54
+ description: 'List items for the signed-in user, paginated.',
55
+ // `input` is inferred as ListInput<{ status?: string }> from the generics above —
56
+ // never re-annotate it inline.
57
+ func: async ({ kysely }, input, { session }) => {
58
+ // `limit` is caller-supplied on an exposed RPC, so it is CAPPED, not trusted —
59
+ // `ListInput` says "server may cap" and this is where that happens.
60
+ const limit = Math.min(Math.max(Math.trunc(input.limit ?? 20) || 20, 1), 100)
61
+ const parsed = input.cursor ? Number(input.cursor) : 0
62
+ const offset = Number.isSafeInteger(parsed) && parsed >= 0 ? parsed : 0
63
+
64
+ let query = kysely.selectFrom('item').where('userId', '=', session!.userId)
65
+ const status = leafEquals(input.filter, 'status')
66
+ if (status !== undefined) {
67
+ query = query.where('status', '=', status)
68
+ }
69
+
70
+ const rows = await query.orderBy('createdAt', 'desc').offset(offset).limit(limit).execute()
71
+ const nextOffset = offset + rows.length
72
+ const totalCount = await query
73
+ .select((eb) => eb.fn.countAll<number>().as('count'))
74
+ .executeTakeFirstOrThrow()
75
+
76
+ return {
77
+ rows: rows.map((r) => ({ id: r.id, label: r.label })),
78
+ nextCursor: nextOffset < totalCount.count ? String(nextOffset) : null,
79
+ totalCount: totalCount.count,
80
+ }
81
+ },
82
+ })
83
+ ```
84
+
85
+ Cursor doesn't have to be a numeric offset — any opaque string works (a keyset value, an encoded timestamp, etc.), as long as you can turn it back into a query position on the next call.
86
+
87
+ ## Frontend: `usePikkuInfiniteQuery`
88
+
89
+ Generated automatically alongside `usePikkuQuery`/`usePikkuMutation` once `reactQueryFile` is configured (see the react-query wiring docs) — no separate setup for list functions specifically.
90
+
91
+ ```tsx
92
+ import { usePikkuInfiniteQuery } from '.pikku/pikku-react-query.gen'
93
+
94
+ function ItemList() {
95
+ const { data, fetchNextPage, hasNextPage, isFetchingNextPage } = usePikkuInfiniteQuery(
96
+ 'listItems',
97
+ { limit: 20 }, // never pass cursor here — the hook manages it
98
+ )
99
+
100
+ const rows = data?.pages.flatMap((page) => page.rows) ?? []
101
+
102
+ return (
103
+ <>
104
+ {rows.map((row) => (
105
+ <div key={row.id}>{row.label}</div>
106
+ ))}
107
+ {hasNextPage && (
108
+ <button disabled={isFetchingNextPage} onClick={() => fetchNextPage()}>
109
+ Load more
110
+ </button>
111
+ )}
112
+ </>
113
+ )
114
+ }
115
+ ```
116
+
117
+ For scroll-triggered loading (rather than a button), pair it with an `IntersectionObserver` sentinel at the end of the list that calls `fetchNextPage()` when it enters the viewport and `hasNextPage` is true — don't poll on a scroll event handler.
118
+
119
+ ## Common mistakes
120
+
121
+ - **Bespoke output shape** (`{items, total}` with no `nextCursor`) — compiles, but disqualifies the function from `usePikkuInfiniteQuery`; you're left hand-rolling pagination state. Use `ListOutput<Row>`'s field names (`rows`, `nextCursor`) even if you don't need `filter`/`sort`/`search` yet — they're optional.
122
+ - **Fixed large `limit` instead of real pagination** (e.g. `{ limit: 500 }` fetched once) — works until the collection outgrows the cap, then silently truncates. If a list can grow unbounded, page it from the start.
123
+ - **Passing `cursor` manually into `usePikkuInfiniteQuery`'s input argument** — the hook injects it into each page request itself; the input you pass is the _base_ filter/limit shared by every page.
124
+
125
+ ## `filter` is a TREE, not a bag of fields
126
+
127
+ `Filter<F>` is recursive: an **array** is an AND of its children, a **multi-key object** is
128
+ an OR keyed by labels that mean nothing at evaluation time, and only a **single-key object**
129
+ is a leaf. A leaf's value is either the value itself or an operator object
130
+ (`{ contains, in, gt, gte, lt, lte, not, startsWith, … }`).
131
+
132
+ So `'status' in input.filter` answers `false` for `[{ status: 'open' }, { userId: 'u1' }]`
133
+ and for `{ status: { in: ['open', 'held'] } }` — the first because the filter is an array,
134
+ the second because the value is an operator object rather than the string the code then
135
+ compares. Both cases **silently return unfiltered rows**, which on a list endpoint means
136
+ handing back records the caller asked to exclude. Pikku ships no filter-to-SQL helper: the
137
+ backend decides what it accepts, and it has to say so.
138
+
139
+ Read exactly the shape you support, and refuse the rest rather than ignoring it:
140
+
141
+ ```typescript
142
+ import type { Filter } from '@pikku/core/function'
143
+
144
+ /** The one shape this endpoint accepts: a single-key leaf with a plain value. */
145
+ function leafEquals<F extends Record<string, unknown>, K extends keyof F & string>(
146
+ filter: Filter<F> | undefined,
147
+ field: K,
148
+ ): F[K] | undefined {
149
+ if (!filter || Array.isArray(filter)) return undefined
150
+ const keys = Object.keys(filter)
151
+ if (keys.length !== 1 || keys[0] !== field) return undefined
152
+ const value = (filter as Record<string, unknown>)[field]
153
+ if (value !== null && typeof value === 'object') {
154
+ throw new Error(`filter.${field} takes a value, not an operator object`)
155
+ }
156
+ return value as F[K]
157
+ }
158
+ ```
159
+
160
+ Supporting AND/OR or operators means walking the tree properly — recurse into the array and
161
+ the multi-key object, and map each leaf operator to its Kysely equivalent. Do that when the
162
+ UI needs it; until then, throwing on the shapes you do not handle is what stops a filter
163
+ from being quietly dropped.