@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/CHANGELOG.md +33 -0
- package/dist/skills.gen.js +1 -1
- package/package.json +1 -1
- package/skills/pikku-a11y/SKILL.md +59 -0
- package/skills/pikku-architect/SKILL.md +1 -0
- package/skills/pikku-auth/references/better-auth.md +125 -7
- package/skills/pikku-build/SKILL.md +1 -0
- package/skills/pikku-build/references/app.md +1 -1
- package/skills/pikku-build/references/multi-app.md +55 -0
- package/skills/pikku-build/references/ship.md +2 -2
- package/skills/pikku-fabric/SKILL.md +35 -18
- package/skills/pikku-i18n/SKILL.md +5 -3
- package/skills/pikku-knowledge/SKILL.md +1 -0
- package/skills/pikku-list-query/SKILL.md +163 -0
- package/skills/pikku-permissions/SKILL.md +126 -0
- package/skills/pikku-react/references/client.md +20 -0
- package/skills/pikku-realtime/SKILL.md +147 -0
- package/skills/pikku-seo/SKILL.md +133 -0
- package/skills/pikku-software-archaeology/SKILL.md +1 -0
- package/skills/pikku-webhook/SKILL.md +25 -0
- package/skills/pikku-workflow/SKILL.md +37 -0
package/package.json
CHANGED
|
@@ -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: [
|
|
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
|
|
485
|
-
|
|
486
|
-
|
|
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
|
|
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
|
|
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
|
|
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;
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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,
|
|
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
|
-
|
|
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 —
|
|
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.
|
|
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
|
-
|
|
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
|
|
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"
|
|
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 `--
|
|
369
|
+
combined with a branch or `--production`, which would let the two disagree.
|
|
353
370
|
|
|
354
|
-
Under `--json`,
|
|
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
|
|
67
|
-
|
|
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.
|