@pikku/skills 0.12.27 → 0.12.28
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 +31 -0
- package/dist/skills.gen.js +1 -1
- package/package.json +1 -1
- package/skills/pikku-knowledge/SKILL.md +28 -1
- package/skills/pikku-kysely/SKILL.md +107 -3
package/package.json
CHANGED
|
@@ -141,7 +141,8 @@ And writing again replaces it rather than adding a second
|
|
|
141
141
|
```
|
|
142
142
|
````
|
|
143
143
|
|
|
144
|
-
- **`status`** is `proposed` → `dispatched` → `built`. Nothing else. Every gate compares it literally.
|
|
144
|
+
- **`status`** is `designing` → `proposed` → `dispatched` → `built`. Nothing else. Every gate compares it literally. `designing` sits BEFORE `proposed`: the slice is written down but must not be built yet, because whoever is being shown its looks has not picked one. Only `proposed` is dispatchable, so the two cannot be one status without a slice being built out from under the person still choosing.
|
|
145
|
+
- **`statusAt:` and `attempts:` are bookkeeping, not content — never hand-edit them.** A loop driving this base writes both. `statusAt:` is stamped by whatever moved the status, and is what makes "how long has this been building?" answerable; the file's mtime is not the transition time, because a note is edited after dispatch for all sorts of reasons. `attempts:` is `seat@hash` entries recording which seat has already tried to move this note forward, against the content it was trying to move — it is the loop's only brake, and clearing it by hand hands back a budget that exists to stop a note nothing can satisfy being rewritten forever. Rewriting the note's real content refunds that budget on its own, which is the point: an answer that changes the note is what unsticks it.
|
|
145
146
|
- **`entities`** lists what the slice touches, **at most three**. Past three it is not one buildable piece — split it.
|
|
146
147
|
- **The scenario is a fenced `gherkin` block, in the third person.** `Given 'owner' has no entry` — never `Given I have no entry`. A quoted word _means a persona_, which is what lets a reader (and a test) tell who is acting. First person hides that, so it is rejected. The console draws the keywords as a column and each quoted persona as a chip, so a first-person scenario is visibly a block with no personas in it.
|
|
147
148
|
|
|
@@ -243,6 +244,32 @@ pikku knowledge index --check # report stale indexes without writing (CI gate)
|
|
|
243
244
|
|
|
244
245
|
`index` rewrites only the block between `<!-- pikku:knowledge-index -->` markers, creating a scaffolded `index.md` for a section that has none. It is idempotent — running it twice changes nothing.
|
|
245
246
|
|
|
247
|
+
### What to do next
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
pikku knowledge next # the one thing to do next, derived from what is on disk
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
`next` is a pure read: it looks at the notes and answers with exactly one action —
|
|
254
|
+
`repair-note`, `write-plan`, `ask-user`, `dispatch`, `hold`, or `idle`. Nothing has to
|
|
255
|
+
be armed by whoever noticed a transition, so calling it twice is free and a state
|
|
256
|
+
nobody anticipated is a missing answer rather than a run that quietly stops.
|
|
257
|
+
|
|
258
|
+
Two things about the output matter if you are driving it:
|
|
259
|
+
|
|
260
|
+
- **`reason` is machine wording.** It names the note, the frontmatter key and what the
|
|
261
|
+
gate wanted. Never repeat it to a person — they have not seen a note and it will read
|
|
262
|
+
as gibberish about files.
|
|
263
|
+
- **`ask-user` carries a `question` as well.** That IS the version for a person: a
|
|
264
|
+
`header`, the question in the language of their app, and `options` when the answer
|
|
265
|
+
comes from a closed vocabulary (which `status:` it is, which `surface:` it is).
|
|
266
|
+
`options` is empty when the answer is free text, and an empty list means offer free
|
|
267
|
+
text — never invent choices to fill it.
|
|
268
|
+
|
|
269
|
+
`hold` means a profile's own gate is holding the milestone and no seat this loop knows
|
|
270
|
+
about can clear it. It names the hold and the notes it is about; what to do then
|
|
271
|
+
belongs to that profile, not here.
|
|
272
|
+
|
|
246
273
|
### The milestone plan
|
|
247
274
|
|
|
248
275
|
A milestone note says what the app must DO. Its **plan** — JSON beside the note, not prose — says what has to exist for it, and is what a finished build is measured against:
|
|
@@ -4,12 +4,13 @@ description: >-
|
|
|
4
4
|
Use when WRITING KYSELY QUERIES (select/join/aggregate/insert/update/delete) inside a Pikku
|
|
5
5
|
function body, or when setting up SQL database services with Kysely. Covers the query builder
|
|
6
6
|
API (joins, aggregates + groupBy/having, returning, sql template, expression builder, $if,
|
|
7
|
-
transactions, jsonArrayFrom relation helpers)
|
|
7
|
+
transactions, jsonArrayFrom relation helpers), HOW MANY ROUND TRIPS a function body costs and how to
|
|
8
|
+
collapse sequential awaits into one statement, AND @pikku/kysely service setup (channel stores,
|
|
8
9
|
workflow services, secret services, AI storage, deployment services). TRIGGER when: writing any
|
|
9
10
|
non-trivial kysely query (a join, an aggregate/count/sum, groupBy, subquery, transaction, or
|
|
10
11
|
conditional query), the injected `kysely` service is used in a function body, or code uses
|
|
11
|
-
PikkuKysely, KyselyChannelStore, KyselyWorkflowService, KyselySecretService,
|
|
12
|
-
about SQL setup with Pikku. DO NOT TRIGGER when: user asks about MongoDB or Redis-backed
|
|
12
|
+
PikkuKysely, KyselyChannelStore, KyselyWorkflowService, KyselySecretService, a function body
|
|
13
|
+
awaits more than one query, or the user asks about SQL setup with Pikku. DO NOT TRIGGER when: user asks about MongoDB or Redis-backed
|
|
13
14
|
services (use pikku-service-backends).
|
|
14
15
|
installGroups: [core]
|
|
15
16
|
---
|
|
@@ -119,6 +120,109 @@ await kysely.transaction().execute(async (trx) => {
|
|
|
119
120
|
})
|
|
120
121
|
```
|
|
121
122
|
|
|
123
|
+
## One statement, not five
|
|
124
|
+
|
|
125
|
+
**Count the `await`s in the function body before you finish it.** In a deployed
|
|
126
|
+
stage the database is not in the process — every terminal (`.execute()`,
|
|
127
|
+
`.executeTakeFirst()`) is a network hop, and five in a row is five latencies the
|
|
128
|
+
caller waits through in series. This is the single most common thing wrong with a
|
|
129
|
+
generated function body, and it never shows up locally against a socket on the
|
|
130
|
+
same machine.
|
|
131
|
+
|
|
132
|
+
**Sequential is only correct when the second query needs the first one's
|
|
133
|
+
VALUES.** Everything else is one of these four:
|
|
134
|
+
|
|
135
|
+
**1. Independent reads → `Promise.all`.** Nothing about the SQL changes; they
|
|
136
|
+
just stop queuing behind each other.
|
|
137
|
+
|
|
138
|
+
```typescript
|
|
139
|
+
// Three hops, in series
|
|
140
|
+
const item = await kysely.selectFrom('item').where('id','=',id).selectAll().executeTakeFirst()
|
|
141
|
+
const bins = await kysely.selectFrom('bin').where('warehouseId','=',wid).selectAll().execute()
|
|
142
|
+
const moves = await kysely.selectFrom('stockMove').where('itemId','=',id).selectAll().execute()
|
|
143
|
+
|
|
144
|
+
// One hop's worth of latency
|
|
145
|
+
const [item, bins, moves] = await Promise.all([
|
|
146
|
+
kysely.selectFrom('item').where('id','=',id).selectAll().executeTakeFirst(),
|
|
147
|
+
kysely.selectFrom('bin').where('warehouseId','=',wid).selectAll().execute(),
|
|
148
|
+
kysely.selectFrom('stockMove').where('itemId','=',id).selectAll().execute(),
|
|
149
|
+
])
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**2. Parent, then its children → `jsonArrayFrom` / `jsonObjectFrom`.** A loop
|
|
153
|
+
containing an `await` is an N+1: one query per row, so the cost is the size of the
|
|
154
|
+
result set rather than the size of the code. **Never `await` inside a `for`/`map`
|
|
155
|
+
over rows you just fetched.** The relation helpers in the cookbook above collapse
|
|
156
|
+
it into one statement that returns the nested shape your output schema already
|
|
157
|
+
wants.
|
|
158
|
+
|
|
159
|
+
```typescript
|
|
160
|
+
// N+1 — one extra hop per warehouse
|
|
161
|
+
const warehouses = await kysely.selectFrom('warehouse').selectAll().execute()
|
|
162
|
+
for (const w of warehouses) {
|
|
163
|
+
w.bins = await kysely.selectFrom('bin').where('warehouseId','=',w.id).selectAll().execute()
|
|
164
|
+
}
|
|
165
|
+
// One hop — see NESTED DATA above
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
If the shape genuinely cannot be nested, fetch the children in **one** query with
|
|
169
|
+
`where('warehouseId', 'in', warehouses.map((w) => w.id))` and group them in JS.
|
|
170
|
+
Two hops beats N.
|
|
171
|
+
|
|
172
|
+
**3. Read, decide, write → one write that returns.** A `select` to check
|
|
173
|
+
existence followed by an `insert` is both two hops and a race — another request
|
|
174
|
+
can insert between them. `returning()` and `onConflict` do it in one statement,
|
|
175
|
+
and what comes back tells you which branch happened.
|
|
176
|
+
|
|
177
|
+
```typescript
|
|
178
|
+
// Two hops and a race
|
|
179
|
+
const existing = await kysely.selectFrom('item').where('sku','=',sku).select('id').executeTakeFirst()
|
|
180
|
+
if (existing) throw new ConflictError()
|
|
181
|
+
await kysely.insertInto('item').values({ sku, name }).execute()
|
|
182
|
+
|
|
183
|
+
// One hop, and the database arbitrates
|
|
184
|
+
const created = await kysely
|
|
185
|
+
.insertInto('item')
|
|
186
|
+
.values({ sku, name })
|
|
187
|
+
.onConflict((oc) => oc.column('sku').doNothing())
|
|
188
|
+
.returning(['id', 'sku'])
|
|
189
|
+
.executeTakeFirst()
|
|
190
|
+
if (!created) throw new ConflictError()
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
The same applies to fetch-then-update: `updateTable(...).where(...).returning(...)`
|
|
194
|
+
in one call, and `undefined` back means the row was not there — that is your
|
|
195
|
+
`NotFoundError`, not a reason for a preceding `select`. Many single-row inserts
|
|
196
|
+
are one `.values([...])` with an array.
|
|
197
|
+
|
|
198
|
+
**4. A read that only feeds the next query's `where` → a subquery or a CTE.**
|
|
199
|
+
If the first result never reaches the response and never reaches JS, it should
|
|
200
|
+
never have crossed the wire.
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
// Two hops — the ids are only ever used as a filter
|
|
204
|
+
const ids = await kysely.selectFrom('bin').where('warehouseId','=',wid).select('id').execute()
|
|
205
|
+
const stock = await kysely.selectFrom('stock').where('binId','in', ids.map((b) => b.id)).selectAll().execute()
|
|
206
|
+
|
|
207
|
+
// One hop
|
|
208
|
+
const stock = await kysely
|
|
209
|
+
.selectFrom('stock')
|
|
210
|
+
.where('binId', 'in', (eb) =>
|
|
211
|
+
eb.selectFrom('bin').select('bin.id').where('bin.warehouseId', '=', wid)
|
|
212
|
+
)
|
|
213
|
+
.selectAll()
|
|
214
|
+
.execute()
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
`.with('name', (db) => ...)` builds a CTE when the same intermediate is needed
|
|
218
|
+
twice inside one statement. A total alongside a page is a window function —
|
|
219
|
+
`eb.fn.countAll<number>().over().as('total')` — not a second `count` query.
|
|
220
|
+
|
|
221
|
+
**A transaction does NOT reduce round trips.** It adds `BEGIN` and `COMMIT` around
|
|
222
|
+
whatever is inside it. Reach for it when several writes must land together or not
|
|
223
|
+
at all; never as a way to make sequential queries cheaper, and never wrapped round
|
|
224
|
+
reads that only needed `Promise.all`.
|
|
225
|
+
|
|
122
226
|
Pikku provides SQL database services through six packages:
|
|
123
227
|
|
|
124
228
|
- `@pikku/kysely` — Base service implementations (database-agnostic), the serialize plugins, `createAuditedKysely` and the `pikkuSchemas` helpers
|