@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.
- package/CHANGELOG.md +41 -0
- package/dist/skills.gen.js +1 -1
- package/package.json +1 -1
- package/skills/pikku-admin-to-fabric/SKILL.md +212 -0
- package/skills/pikku-blueprint-to-fabric/SKILL.md +377 -0
- package/skills/pikku-blueprint-to-fabric/scripts/inventory.mjs +367 -0
- package/skills/pikku-knowledge/SKILL.md +28 -1
- package/skills/pikku-kysely/SKILL.md +107 -3
|
@@ -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)
|
|
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
|