@pikku/skills 0.12.26 → 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.
@@ -0,0 +1,367 @@
1
+ #!/usr/bin/env node
2
+ // Emit the Pikku implementation inventory for a .knowledge/ blueprint.
3
+ //
4
+ // node inventory.mjs <path-to-.knowledge> [--json] [--domain <Name>]
5
+ //
6
+ // Answers "what will actually be built?" BEFORE any code exists: every pikkuFunc,
7
+ // permission, scheduler, queue worker, workflow, HTTP wiring, event channel,
8
+ // table and scenario — plus, crucially, what is BLOCKED by an unresolved decision
9
+ // and what is deliberately NOT built.
10
+ //
11
+ // This is a projection of the blueprint, not a plan you write by hand. It is
12
+ // derived, so it stays honest: if it says 187 functions, the blueprint says 187.
13
+
14
+ import { readFileSync, existsSync } from 'node:fs'
15
+ import { join } from 'node:path'
16
+
17
+ const dir = process.argv[2]
18
+ const asJson = process.argv.includes('--json')
19
+ const domainArg = process.argv.includes('--domain')
20
+ ? process.argv[process.argv.indexOf('--domain') + 1]
21
+ : null
22
+
23
+ if (!dir) {
24
+ console.error('usage: inventory.mjs <path-to-.knowledge> [--json] [--domain <Name>]')
25
+ process.exit(2)
26
+ }
27
+
28
+ // The four files pikku-software-archaeology's schema marks `x-optional` — an
29
+ // API-only app really has no frontend. Every other file is REQUIRED, and a
30
+ // missing or unparseable one is fatal rather than an empty list: this script's
31
+ // whole claim is that its counts come from the blueprint, so silently reporting
32
+ // "0 commands" for a directory that isn't a blueprint tells the exact lie the
33
+ // inventory exists to prevent.
34
+ const OPTIONAL = new Set([
35
+ 'interfaces.json',
36
+ 'frontend.json',
37
+ 'frontend-routes.json',
38
+ 'frontend-components.json',
39
+ ])
40
+
41
+ const fatal = (msg) => {
42
+ console.error(`inventory: ${msg}`)
43
+ process.exit(1)
44
+ }
45
+
46
+ if (!existsSync(dir)) fatal(`no such directory: ${dir}`)
47
+
48
+ const read = (f) => {
49
+ const p = join(dir, f)
50
+ if (!existsSync(p)) {
51
+ if (OPTIONAL.has(f)) return null
52
+ fatal(
53
+ `${dir} is missing ${f}. Run the archaeology validator first — ` +
54
+ `an incomplete blueprint gives a count that reads as complete.`
55
+ )
56
+ }
57
+ try {
58
+ return JSON.parse(readFileSync(p, 'utf-8'))
59
+ } catch (e) {
60
+ fatal(`${f} is not valid JSON: ${e.message}`)
61
+ }
62
+ }
63
+
64
+ /** A required file that parsed but carries the wrong shape is the same lie. */
65
+ const list = (f, key) => {
66
+ const doc = read(f)
67
+ if (doc === null) return []
68
+ const rows = doc[key]
69
+ if (rows === undefined) fatal(`${f} has no "${key}" array — blueprint shape changed?`)
70
+ if (!Array.isArray(rows)) fatal(`${f}: "${key}" is ${typeof rows}, expected an array`)
71
+ return rows
72
+ }
73
+
74
+ const domains = list('domains.json', 'domains')
75
+ const entities = list('entities.json', 'entities')
76
+ const commands = list('commands.json', 'commands')
77
+ const queries = list('queries.json', 'queries')
78
+ const events = list('events.json', 'events')
79
+ const policies = list('policies.json', 'policies')
80
+ const workflows = list('workflows.json', 'workflows')
81
+ const apiDoc = read('api.json')
82
+ const api = apiDoc.surfaces ?? apiDoc.api ?? fatal('api.json has neither "surfaces" nor "api"')
83
+ const integrations = list('integrations.json', 'integrations')
84
+ const migration = read('migration.json')
85
+ const interfaces = read('interfaces.json')?.interfaces ?? []
86
+ const feComponents = read('frontend-components.json')?.components ?? []
87
+ const feRoutes = read('frontend-routes.json')?.routes ?? []
88
+
89
+ // ---- the decisions gate -----------------------------------------------------
90
+ // A concept named in an unresolved decision's blockedConcepts cannot be built.
91
+ //
92
+ // Answers are recorded OUTSIDE the blueprint — in the rebuild's own knowledge
93
+ // base — because the blueprint is a record of the LEGACY app and must not be
94
+ // rewritten as the rebuild proceeds. So pass the ones already answered:
95
+ //
96
+ // --resolved 1,2,3 (1-based index into migration.json.decisionsNeeded)
97
+ //
98
+ // Anything not listed is still blocking.
99
+ const allDecisions = migration.decisionsNeeded ?? []
100
+ const resolvedArg = process.argv.includes('--resolved')
101
+ ? process.argv[process.argv.indexOf('--resolved') + 1]
102
+ : ''
103
+ const resolvedIdx = new Set(
104
+ resolvedArg.split(',').map((s) => parseInt(s.trim(), 10)).filter(Boolean)
105
+ )
106
+ const decisions = allDecisions.filter((_, i) => !resolvedIdx.has(i + 1))
107
+ const resolved = allDecisions.filter((_, i) => resolvedIdx.has(i + 1))
108
+ const blocked = new Map() // concept -> question
109
+ for (const d of decisions) {
110
+ for (const c of d.blockedConcepts ?? []) {
111
+ if (!blocked.has(c)) blocked.set(c, d.question)
112
+ }
113
+ }
114
+ const dropped = migration.dropped ?? []
115
+ const droppedNames = new Set(
116
+ dropped.map((d) => (d.path ?? d.file ?? '').split('/').pop())
117
+ )
118
+
119
+ const isBlocked = (name, domain) => blocked.has(name) || blocked.has(domain)
120
+
121
+ // ---- classification ---------------------------------------------------------
122
+ // Cron-ish triggers. The blueprint records schedules in prose ("daily at 05:00",
123
+ // "every minute"), because that is how the legacy code expressed them.
124
+ const CRON = /(cron|daily|nightly|hourly|every minute|every \d|schedule|at \d{2}:\d{2})/i
125
+ const QUEUE = /(queue|consumer|perform_later|sidekiq|worker|async)/i
126
+ const WEBHOOK = /(webhook|POST \/webhooks)/i
127
+ // A trigger that fires off a row write is the legacy shape of an EVENT, not of a
128
+ // workflow — a handler doing five unrelated things because there was no bus.
129
+ // These become an event + its consumers, which is the structural upgrade.
130
+ const EVENT = /(after_commit|after_save|on create\/update\/destroy|observer|callback|mirror)/i
131
+
132
+ const classifyWorkflow = (w) => {
133
+ const t = `${w.name} ${w.trigger ?? ''}`
134
+ if (w.kind === 'system') {
135
+ if (WEBHOOK.test(t)) return 'wireHTTP + command (webhook ingress)'
136
+ if (CRON.test(t)) return 'wireScheduler'
137
+ if (EVENT.test(t)) return 'event consumer (realtime/queue)'
138
+ if (QUEUE.test(t)) return 'wireQueueWorker'
139
+ return 'pikkuWorkflowFunc'
140
+ }
141
+ // User and admin journeys are NOT pikku workflows — they are sequences of
142
+ // commands a person drives through the UI. They become scenarios.
143
+ return 'scenario (user journey)'
144
+ }
145
+
146
+ // HTTP is warranted ONLY where the caller is a system we do not control and
147
+ // cannot ask to speak RPC — i.e. it POSTs to a URL we publish, on its schedule.
148
+ // In practice that means inbound webhooks.
149
+ //
150
+ // Everything else is RPC, including surfaces that look like they need HTTP:
151
+ // - `auth: none` means anonymous, not external. Our own sign-up page calls it.
152
+ // - a tokened/capability URL (a check-in link, a public share link) is a
153
+ // first-party page reading a token; the page can call RPC like any other.
154
+ // The caller being ours is what decides this, not the URL's shape or its auth.
155
+ const needsHttp = (s) => /webhook/i.test(s.path ?? '')
156
+
157
+ const byDomain = (arr) => {
158
+ const m = new Map()
159
+ for (const x of arr) {
160
+ const d = x.domain ?? 'unassigned'
161
+ if (!m.has(d)) m.set(d, [])
162
+ m.get(d).push(x)
163
+ }
164
+ return m
165
+ }
166
+
167
+ const cmdByDomain = byDomain(commands)
168
+ const qryByDomain = byDomain(queries)
169
+ const polByDomain = byDomain(policies)
170
+ const evtByDomain = byDomain(events)
171
+ const entByDomain = byDomain(entities)
172
+
173
+ const schedulers = workflows.filter((w) => classifyWorkflow(w) === 'wireScheduler')
174
+ const queueWorkers = workflows.filter((w) => classifyWorkflow(w) === 'wireQueueWorker')
175
+ const ingress = workflows.filter((w) => classifyWorkflow(w).startsWith('wireHTTP'))
176
+ const eventConsumers = workflows.filter((w) => classifyWorkflow(w) === 'event consumer (realtime/queue)')
177
+ const pikkuWorkflows = workflows.filter((w) => classifyWorkflow(w) === 'pikkuWorkflowFunc')
178
+ const journeys = workflows.filter((w) => classifyWorkflow(w) === 'scenario (user journey)')
179
+ const httpSurfaces = api.filter(needsHttp)
180
+ const scenarioCount = workflows.reduce((n, w) => n + (w.scenarios ?? []).length, 0)
181
+
182
+ const blockedCommands = commands.filter((c) => isBlocked(c.name, c.domain))
183
+ const blockedQueries = queries.filter((q) => isBlocked(q.name, q.domain))
184
+ const customLogic = feComponents.filter((c) => c.rebuild === 'custom-logic')
185
+ const cheapComponents = feComponents.filter((c) => c.rebuild !== 'custom-logic')
186
+
187
+ // ---- addons -----------------------------------------------------------------
188
+ // An addon is warranted where a capability is self-contained, reused across
189
+ // domains, and has a clear service seam — which is what a `hard`/`critical`
190
+ // integration is. Advisory: the call is the operator's.
191
+ const addonCandidates = integrations.filter(
192
+ (i) => i.importance === 'critical' || i.replacementDifficulty === 'hard'
193
+ )
194
+
195
+ if (asJson) {
196
+ console.log(
197
+ JSON.stringify(
198
+ {
199
+ totals: {
200
+ pikkuFunc: commands.length,
201
+ pikkuSessionlessFuncReadonly: queries.length,
202
+ pikkuPermission: policies.length,
203
+ wireScheduler: schedulers.length,
204
+ wireQueueWorker: queueWorkers.length,
205
+ webhookIngress: ingress.length,
206
+ pikkuWorkflowFunc: pikkuWorkflows.length,
207
+ wireHTTP: httpSurfaces.length,
208
+ eventChannels: events.length,
209
+ tables: entities.length,
210
+ scenarios: scenarioCount,
211
+ blockedCommands: blockedCommands.length,
212
+ blockedQueries: blockedQueries.length,
213
+ droppedArtifacts: dropped.length,
214
+ customLogicComponents: customLogic.length,
215
+ },
216
+ blocked: [...blocked.entries()].map(([concept, question]) => ({ concept, question })),
217
+ },
218
+ null,
219
+ 2
220
+ )
221
+ )
222
+ process.exit(0)
223
+ }
224
+
225
+ // ---- report -----------------------------------------------------------------
226
+ const out = []
227
+ const p = (s = '') => out.push(s)
228
+
229
+ p('# Pikku implementation inventory')
230
+ p('')
231
+ p(`Derived from \`${dir}\`. Every number below is a projection of the blueprint —`)
232
+ p('nothing here is estimated or invented.')
233
+ p('')
234
+
235
+ p('## Totals')
236
+ p('')
237
+ p('| Pikku artifact | Count | From |')
238
+ p('|---|---:|---|')
239
+ p(`| \`pikkuFunc\` (state-changing) | ${commands.length} | \`commands.json\` |`)
240
+ p(`| \`pikkuSessionlessFunc\` + \`readonly: true\` | ${queries.length} | \`queries.json\` |`)
241
+ p(`| \`pikkuPermission\` | ${policies.length} | \`policies.json\` |`)
242
+ p(`| \`wireScheduler\` | ${schedulers.length} | \`workflows.json\` (system + cron) |`)
243
+ p(`| \`wireQueueWorker\` | ${queueWorkers.length} | \`workflows.json\` (system + queue) |`)
244
+ p(`| webhook ingress (\`wireHTTP\` + command) | ${ingress.length} | \`workflows.json\` (system + webhook) |`)
245
+ p(`| \`pikkuWorkflowFunc\` | ${pikkuWorkflows.length} | \`workflows.json\` (system, multi-step) |`)
246
+ p(`| \`wireHTTP\` (fixed external URLs only) | ${httpSurfaces.length} of ${api.length} surfaces | \`api.json\` |`)
247
+ p(`| event channels (realtime/queue) | ${events.length} | \`events.json\` |`)
248
+ p(`| tables | ${entities.length} | \`entities.json\` |`)
249
+ p(`| scenarios | ${scenarioCount} | \`workflows.json[].scenarios\` |`)
250
+ p('')
251
+ p(`**Not built:** ${dropped.length} dropped artifacts · **Blocked:** ${blockedCommands.length + blockedQueries.length} concepts behind ${decisions.length} open decisions.`)
252
+ p('')
253
+
254
+ if (resolved.length) {
255
+ p('## Decisions already taken')
256
+ p('')
257
+ p('Answered at the gate and recorded in the rebuild\'s knowledge base. Each is a')
258
+ p('**deliberate behaviour change** and belongs in the parity report.')
259
+ p('')
260
+ for (const d of resolved) p(`- ${(d.blockedConcepts ?? []).join(', ')} — _${d.question.split('.')[0]}._`)
261
+ p('')
262
+ }
263
+
264
+ p('## Blocked by an open decision')
265
+ p('')
266
+ if (decisions.length === 0) {
267
+ p('_None — the gate is clear._')
268
+ } else {
269
+ p('These cannot be built until the question is answered. They block **their own')
270
+ p('domains only** — every other slice proceeds.')
271
+ p('')
272
+ for (const d of decisions) {
273
+ p(`- **${(d.blockedConcepts ?? []).join(', ') || '(unscoped)'}**`)
274
+ p(` <br>${d.question}`)
275
+ if (d.options) p(` <br>_Options:_ ${d.options.join(' · ')}`)
276
+ }
277
+ }
278
+ p('')
279
+
280
+ p('## Scheduled tasks (`wireScheduler`)')
281
+ p('')
282
+ p('| Workflow | Trigger |')
283
+ p('|---|---|')
284
+ for (const w of schedulers) p(`| ${w.name} | ${(w.trigger ?? '').replace(/\|/g, '\\|')} |`)
285
+ p('')
286
+
287
+ if (queueWorkers.length || ingress.length || pikkuWorkflows.length || eventConsumers.length) {
288
+ p('## Other system wiring')
289
+ p('')
290
+ p('| Workflow | Becomes |')
291
+ p('|---|---|')
292
+ for (const w of [...ingress, ...queueWorkers, ...eventConsumers, ...pikkuWorkflows])
293
+ p(`| ${w.name} | \`${classifyWorkflow(w)}\` |`)
294
+ p('')
295
+ }
296
+
297
+ p('## Journeys → scenarios')
298
+ p('')
299
+ p(`${journeys.length} user/admin journeys carrying ${scenarioCount} scenarios excavated from the`)
300
+ p('legacy test suite. These are **not** `pikkuWorkflowFunc`s — they are sequences a')
301
+ p('person drives through the UI, and they become scenario tests.')
302
+ p('')
303
+ p('| Journey | Kind | Scenarios |')
304
+ p('|---|---|---:|')
305
+ for (const w of journeys) p(`| ${w.name} | ${w.kind} | ${(w.scenarios ?? []).length} |`)
306
+ p('')
307
+
308
+ p('## Per domain')
309
+ p('')
310
+ p('| Domain | Tables | `pikkuFunc` | readonly | permissions | events | blocked |')
311
+ p('|---|---:|---:|---:|---:|---:|---:|')
312
+ for (const d of domains) {
313
+ const n = d.name
314
+ const b = (cmdByDomain.get(n) ?? []).filter((c) => isBlocked(c.name, n)).length +
315
+ (qryByDomain.get(n) ?? []).filter((q) => isBlocked(q.name, n)).length
316
+ p(
317
+ `| ${n} | ${(entByDomain.get(n) ?? []).length} | ${(cmdByDomain.get(n) ?? []).length} | ${(qryByDomain.get(n) ?? []).length} | ${(polByDomain.get(n) ?? []).length} | ${(evtByDomain.get(n) ?? []).length} | ${b || ''} |`
318
+ )
319
+ }
320
+ p('')
321
+
322
+ p('## Addon candidates')
323
+ p('')
324
+ p('Advisory. A capability earns an addon when it is self-contained, reused across')
325
+ p('domains, and has a clear service seam — which is what a critical/hard-to-replace')
326
+ p('integration usually is. The call is yours.')
327
+ p('')
328
+ p('| Integration | Importance | Replace |')
329
+ p('|---|---|---|')
330
+ for (const i of addonCandidates)
331
+ p(`| ${i.name} | ${i.importance ?? ''} | ${i.replacementDifficulty ?? ''} |`)
332
+ p('')
333
+
334
+ if (feComponents.length) {
335
+ p('## Frontend')
336
+ p('')
337
+ p(`- **${cheapComponents.length}** components are a cheap re-expression in Mantine (standard / composition / restyle).`)
338
+ p(`- **${customLogic.length}** carry \`custom-logic\` and must be **ported**. This is the real frontend project.`)
339
+ p(`- **${feRoutes.length}** routes → TanStack routes; their \`dataFrom\` names are already the function names above.`)
340
+ p('')
341
+ if (customLogic.length) {
342
+ p('| custom-logic component | What must survive |')
343
+ p('|---|---|')
344
+ for (const c of customLogic)
345
+ p(`| ${c.name} | ${(c.customLogic ?? '').replace(/\|/g, '\\|').slice(0, 110)} |`)
346
+ p('')
347
+ }
348
+ }
349
+
350
+ if (interfaces.length) {
351
+ p('## Interfaces')
352
+ p('')
353
+ p('| Channel | Audience | Status |')
354
+ p('|---|---|---|')
355
+ for (const i of interfaces) p(`| ${i.kind} | ${i.audience ?? ''} | ${i.status ?? ''} |`)
356
+ p('')
357
+ }
358
+
359
+ p('## Deliberately not built')
360
+ p('')
361
+ p('| Artifact | Why |')
362
+ p('|---|---|')
363
+ for (const d of dropped)
364
+ p(`| \`${d.path ?? d.file}\` | ${(d.reason ?? '').replace(/\|/g, '\\|').slice(0, 120)} |`)
365
+ p('')
366
+
367
+ console.log(out.join('\n'))
@@ -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) AND @pikku/kysely service setup (channel stores,
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, or the user asks
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