@pikku/cli 0.12.115 → 0.12.117
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 +270 -0
- package/console-app/assets/{abnfDiagram-N423BO3Z-Bltf5dmS.js → abnfDiagram-N423BO3Z-WNIAg_Oa.js} +1 -1
- package/console-app/assets/architecture-TIHT7OUA-CIT-Nug2.js +1 -0
- package/console-app/assets/{architectureDiagram-T3A2C74G-BTtx6R7-.js → architectureDiagram-T3A2C74G-DM698mgf.js} +1 -1
- package/console-app/assets/{blockDiagram-VBNYF7ZC-DgeTIOaM.js → blockDiagram-VBNYF7ZC-DPfy9K5S.js} +1 -1
- package/console-app/assets/{c4Diagram-5PPSVZJV-CaswnNob.js → c4Diagram-5PPSVZJV-DhLbbBbX.js} +1 -1
- package/console-app/assets/channel-DQC6cL1c.js +1 -0
- package/console-app/assets/{chunk-2GRJ4B5K-OnpnkpQT.js → chunk-2GRJ4B5K-C1RQ1gvs.js} +1 -1
- package/console-app/assets/{chunk-4I5QYGJK-CWmfNXFr.js → chunk-4I5QYGJK-BkzSdNc_.js} +1 -1
- package/console-app/assets/{chunk-5RXB4S5H-M8QmKeuk.js → chunk-5RXB4S5H-DZpBpdcU.js} +1 -1
- package/console-app/assets/{chunk-6Q2QTUOP-C_h5lS68.js → chunk-6Q2QTUOP-mZObxdld.js} +1 -1
- package/console-app/assets/{chunk-7Z6QIM7H-DSnVb09k.js → chunk-7Z6QIM7H-C0HbxZEa.js} +1 -1
- package/console-app/assets/{chunk-GF5L2VYU-BkX038sq.js → chunk-GF5L2VYU-DXPVwWqW.js} +1 -1
- package/console-app/assets/{chunk-I66GZJ75-b3yMIljX.js → chunk-I66GZJ75-pVpq9ckD.js} +1 -1
- package/console-app/assets/{chunk-JQJVKLGR-Bihcz4nv.js → chunk-JQJVKLGR-DJQ8K4wK.js} +1 -1
- package/console-app/assets/{chunk-KBJHAD2P-CS7eflbk.js → chunk-KBJHAD2P-B8XuxRCO.js} +1 -1
- package/console-app/assets/{chunk-NSK5VX7P-CfigFGDQ.js → chunk-NSK5VX7P-cYzQU4bD.js} +1 -1
- package/console-app/assets/{chunk-QR6OTTB3-BBy8NfTW.js → chunk-QR6OTTB3-CNoTFsqn.js} +1 -1
- package/console-app/assets/{chunk-RYQCIY6F-BCfZVQef.js → chunk-RYQCIY6F-DoKRUO_N.js} +1 -1
- package/console-app/assets/{chunk-UBXNYLIW-DkMKDHW4.js → chunk-UBXNYLIW-BqcIoUpU.js} +1 -1
- package/console-app/assets/{chunk-W5SLKNZC-BSLytTSJ.js → chunk-W5SLKNZC-p0Qalbjj.js} +1 -1
- package/console-app/assets/{chunk-WRU74C26-Dic7ZP8H.js → chunk-WRU74C26-DUMn96Dr.js} +1 -1
- package/console-app/assets/chunk-XXDRQBXY-Dgz5VbWO.js +1 -0
- package/console-app/assets/classDiagram-JCYQIIEL-1ASFocNV.js +1 -0
- package/console-app/assets/classDiagram-v2-OCEON4UE-1ASFocNV.js +1 -0
- package/console-app/assets/{cose-bilkent-JH36ORCC-CEpWcE3M.js → cose-bilkent-JH36ORCC-BCh3BlPI.js} +1 -1
- package/console-app/assets/{cynefin-VYW2F7L2-irPiPhgz.js → cynefin-VYW2F7L2-ZfghUZsD.js} +1 -1
- package/console-app/assets/{cynefinDiagram-MW4NZA55-CIkqQ912.js → cynefinDiagram-MW4NZA55-DcV3QXCe.js} +1 -1
- package/console-app/assets/{dagre-FZyyw3H3.js → dagre-BPFWXLBN.js} +1 -1
- package/console-app/assets/{dagre-VZM6K2ZE-Dxnnu7Rh.js → dagre-VZM6K2ZE-DETshBO-.js} +1 -1
- package/console-app/assets/{diagram-7IWD3JNH-DaT47sVs.js → diagram-7IWD3JNH-CsLX3P2o.js} +1 -1
- package/console-app/assets/{diagram-B4RE2ZJO-Dmht03tx.js → diagram-B4RE2ZJO-o2zcxm5S.js} +1 -1
- package/console-app/assets/{diagram-LBJQPF4R-B--jwILh.js → diagram-LBJQPF4R-BD2zKU21.js} +1 -1
- package/console-app/assets/{diagram-Q27KOJAE-D9H0euUr.js → diagram-Q27KOJAE-DHaacyna.js} +1 -1
- package/console-app/assets/{diagram-UB23O5K3-DloUQ8tO.js → diagram-UB23O5K3-pSLCMUyV.js} +1 -1
- package/console-app/assets/{ebnfDiagram-BXEA7PRR-gaOXzNBU.js → ebnfDiagram-BXEA7PRR-BDzawX3O.js} +1 -1
- package/console-app/assets/{erDiagram-JOGREHBK-3X9eTLsh.js → erDiagram-JOGREHBK-CyYnPmbN.js} +1 -1
- package/console-app/assets/eventmodeling-45OFAUF4-BYJ0A5k-.js +1 -0
- package/console-app/assets/flowDiagram-UKHOOZJN-CVn87uiP.js +1 -0
- package/console-app/assets/{ganttDiagram-PKOTCBZU-khAFwSBy.js → ganttDiagram-PKOTCBZU-3fhCBbPA.js} +1 -1
- package/console-app/assets/{gitGraph-TEB2WS4Q-Bb7uENmk.js → gitGraph-TEB2WS4Q-CdLRkVux.js} +1 -1
- package/console-app/assets/{gitGraphDiagram-DS77QQ5N-OkSBM0h0.js → gitGraphDiagram-DS77QQ5N-CC1epdKw.js} +1 -1
- package/console-app/assets/{index-Bn1BoT3w.js → index-BRmokG9t.js} +184 -184
- package/console-app/assets/{index-suVNAhyL.css → index-GruDnAhJ.css} +1 -1
- package/console-app/assets/{info-DKCQHKI2-BH5dbX2B.js → info-DKCQHKI2-BJs8WkzC.js} +1 -1
- package/console-app/assets/{infoDiagram-6WML65LV-IbUl8J2C.js → infoDiagram-6WML65LV-uoOwoIeI.js} +1 -1
- package/console-app/assets/{ishikawaDiagram-WSZJBQD7-CX0gA3de.js → ishikawaDiagram-WSZJBQD7-vCUJhwTi.js} +1 -1
- package/console-app/assets/{journeyDiagram-NVQOT4AX--k2etXu8.js → journeyDiagram-NVQOT4AX-D2QcIlLG.js} +1 -1
- package/console-app/assets/{kanban-definition-27J2QSJJ-D1FvtPpN.js → kanban-definition-27J2QSJJ-JwwKowmR.js} +1 -1
- package/console-app/assets/{line-JLMY_DDK.js → line-h2oRreYF.js} +1 -1
- package/console-app/assets/{linear-CSvLB3cd.js → linear-Dz002X_a.js} +1 -1
- package/console-app/assets/{mermaid-parser.core-CwwjcAjz.js → mermaid-parser.core-DTgmT0AA.js} +3 -3
- package/console-app/assets/{mermaid.core-hmS_M4K5.js → mermaid.core-1jSEDr-j.js} +4 -4
- package/console-app/assets/{mindmap-definition-FAOFIHXS-rTDa_CTp.js → mindmap-definition-FAOFIHXS-C3Txxhj4.js} +1 -1
- package/console-app/assets/{packet-7NZHBO7P-DBUJcYfI.js → packet-7NZHBO7P-BGG7zIK2.js} +1 -1
- package/console-app/assets/{pegDiagram-VL7TDLO6-D42PxXnS.js → pegDiagram-VL7TDLO6--g1r_rov.js} +1 -1
- package/console-app/assets/{pie-RZYD4A2V-dM0uFAE4.js → pie-RZYD4A2V-C5OepMH7.js} +1 -1
- package/console-app/assets/{pieDiagram-7S7Q4E2Y-wCln1ucl.js → pieDiagram-7S7Q4E2Y-DGD_LzGm.js} +1 -1
- package/console-app/assets/{quadrantDiagram-CIZ2JOQS-DrRwS1g5.js → quadrantDiagram-CIZ2JOQS-CyBQQlD2.js} +1 -1
- package/console-app/assets/{radar-I7S5WNFK-BW0V-EGR.js → radar-I7S5WNFK-CaBOAO95.js} +1 -1
- package/console-app/assets/{railroad-3IZDKUUU-FiTHaDbb.js → railroad-3IZDKUUU-2aTWN4Pw.js} +1 -1
- package/console-app/assets/railroad-abnf-AHOZXSZD-TU7k5gB_.js +1 -0
- package/console-app/assets/railroad-ebnf-EBAXGLYW-DbArPBkM.js +1 -0
- package/console-app/assets/railroad-peg-LSFZ7HO6-TzB93z4K.js +1 -0
- package/console-app/assets/{railroadDiagram-AXF67PYL-9ef7j5U7.js → railroadDiagram-AXF67PYL-3Nr0rlTp.js} +1 -1
- package/console-app/assets/{requirementDiagram-LRYGKXZP-Cu90DbEZ.js → requirementDiagram-LRYGKXZP-BN7sEwSa.js} +1 -1
- package/console-app/assets/{sankeyDiagram-W5VNT64P-Bhxmyxaw.js → sankeyDiagram-W5VNT64P-CXZR46wO.js} +1 -1
- package/console-app/assets/{sequenceDiagram-SI44F4Z6-CBsbRTz8.js → sequenceDiagram-SI44F4Z6-DNtmdJ4U.js} +1 -1
- package/console-app/assets/{src-BR8qR3lM.js → src-x1E3pJJ-.js} +1 -1
- package/console-app/assets/{stateDiagram-OKZ733FA-Dr0frETZ.js → stateDiagram-OKZ733FA-B_GyYdl_.js} +1 -1
- package/console-app/assets/stateDiagram-v2-UEYNNEHI-D7V_HITc.js +1 -0
- package/console-app/assets/{swimlanes-SLNWSIFB-CXMjvd_a.js → swimlanes-SLNWSIFB-D4N7VdF7.js} +1 -1
- package/console-app/assets/swimlanesDiagram-ULZ7WXOC-C4XcIHKT.js +8 -0
- package/console-app/assets/{timeline-definition-Z64GVDOM-DpmEZPV0.js → timeline-definition-Z64GVDOM-DU5rsOWQ.js} +1 -1
- package/console-app/assets/{treeView-QDETBFTQ-CjmuinMk.js → treeView-QDETBFTQ-DaKM_9sB.js} +1 -1
- package/console-app/assets/{treemap-6X3UGDF4-BP_fU6Cn.js → treemap-6X3UGDF4-BabsKtZY.js} +1 -1
- package/console-app/assets/{vennDiagram-T6HMQDX7-DgZS7iuW.js → vennDiagram-T6HMQDX7-Dniftpq0.js} +1 -1
- package/console-app/assets/{wardley-OPB4EBWU-CgMPoouN.js → wardley-OPB4EBWU-BWffQNys.js} +1 -1
- package/console-app/assets/{wardleyDiagram-T6FBY63Y-6y-xxP1R.js → wardleyDiagram-T6FBY63Y-CQ74fVdn.js} +1 -1
- package/console-app/assets/{xychartDiagram-ELKLHX3M-DOeIQjYI.js → xychartDiagram-ELKLHX3M-D-XxbGIL.js} +1 -1
- package/console-app/index.html +2 -2
- package/dist/.pikku/addon/index.d.ts +1 -1
- package/dist/.pikku/addon/index.js +1 -1
- package/dist/.pikku/addon/pikku-addon-types.gen.d.ts +1 -1
- package/dist/.pikku/addon/pikku-addon-types.gen.js +1 -1
- package/dist/.pikku/agent/index.d.ts +1 -1
- package/dist/.pikku/agent/index.js +1 -1
- package/dist/.pikku/agent/pikku-agent-types.gen.d.ts +42 -1
- package/dist/.pikku/agent/pikku-agent-types.gen.js +41 -0
- package/dist/.pikku/agent/pikku-model-aliases.gen.js +1 -1
- package/dist/.pikku/auth/index.d.ts +1 -1
- package/dist/.pikku/auth/index.js +1 -1
- package/dist/.pikku/auth/pikku-auth-types.gen.d.ts +2 -19
- package/dist/.pikku/auth/pikku-auth-types.gen.js +2 -19
- package/dist/.pikku/channel/index.d.ts +1 -1
- package/dist/.pikku/channel/index.js +1 -1
- package/dist/.pikku/channel/pikku-channel-types.gen.d.ts +10 -18
- package/dist/.pikku/channel/pikku-channel-types.gen.js +9 -1
- package/dist/.pikku/cli/index.d.ts +1 -1
- package/dist/.pikku/cli/index.js +1 -1
- package/dist/.pikku/cli/pikku-cli-channel.js +6 -6
- package/dist/.pikku/cli/pikku-cli-client.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli-client.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-contracts-meta.gen.json +24 -23
- package/dist/.pikku/cli/pikku-cli-types.gen.d.ts +8 -7
- package/dist/.pikku/cli/pikku-cli-types.gen.js +8 -7
- package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli-wirings-meta.gen.json +51 -23
- package/dist/.pikku/cli/pikku-cli-wirings.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli-wirings.gen.js +1 -1
- package/dist/.pikku/cli/pikku-cli.gen.d.ts +1 -1
- package/dist/.pikku/cli/pikku-cli.gen.js +1 -1
- package/dist/.pikku/error/index.d.ts +1 -1
- package/dist/.pikku/error/index.js +1 -1
- package/dist/.pikku/error/pikku-error-types.gen.d.ts +1 -1
- package/dist/.pikku/error/pikku-error-types.gen.js +1 -1
- package/dist/.pikku/function/index.d.ts +1 -1
- package/dist/.pikku/function/index.js +1 -1
- package/dist/.pikku/function/pikku-function-types.gen.d.ts +36 -37
- package/dist/.pikku/function/pikku-function-types.gen.js +21 -9
- package/dist/.pikku/function/pikku-functions-meta.gen.js +1 -1
- package/dist/.pikku/function/pikku-functions-meta.gen.json +34 -34
- package/dist/.pikku/function/pikku-functions.gen.js +1 -1
- package/dist/.pikku/gateway/index.d.ts +1 -1
- package/dist/.pikku/gateway/index.js +1 -1
- package/dist/.pikku/gateway/pikku-gateway-types.gen.d.ts +1 -1
- package/dist/.pikku/gateway/pikku-gateway-types.gen.js +1 -1
- package/dist/.pikku/http/index.d.ts +1 -1
- package/dist/.pikku/http/index.js +1 -1
- package/dist/.pikku/http/pikku-http-types.gen.d.ts +7 -1
- package/dist/.pikku/http/pikku-http-types.gen.js +5 -1
- package/dist/.pikku/mcp/index.d.ts +1 -1
- package/dist/.pikku/mcp/index.js +1 -1
- package/dist/.pikku/mcp/pikku-mcp-types.gen.d.ts +11 -1
- package/dist/.pikku/mcp/pikku-mcp-types.gen.js +5 -1
- package/dist/.pikku/middleware/index.d.ts +1 -1
- package/dist/.pikku/middleware/index.js +1 -1
- package/dist/.pikku/middleware/pikku-middleware-types.gen.d.ts +33 -24
- package/dist/.pikku/middleware/pikku-middleware-types.gen.js +29 -24
- package/dist/.pikku/pikku-bootstrap-scenarios.gen.d.ts +1 -1
- package/dist/.pikku/pikku-bootstrap-scenarios.gen.js +1 -1
- package/dist/.pikku/pikku-bootstrap.gen.d.ts +1 -1
- package/dist/.pikku/pikku-bootstrap.gen.js +1 -1
- package/dist/.pikku/pikku-services.gen.d.ts +1 -1
- package/dist/.pikku/queue/index.d.ts +1 -1
- package/dist/.pikku/queue/index.js +1 -1
- package/dist/.pikku/queue/pikku-queue-types.gen.d.ts +3 -1
- package/dist/.pikku/queue/pikku-queue-types.gen.js +3 -1
- package/dist/.pikku/queue/pikku-queue-workers-wirings-meta.gen.js +1 -1
- package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.d.ts +1 -1
- package/dist/.pikku/queue/pikku-queue-workers-wirings.gen.js +1 -1
- package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.js +1 -1
- package/dist/.pikku/rpc/pikku-rpc-wirings-meta.internal.gen.json +1 -1
- package/dist/.pikku/scenarios/index.d.ts +1 -1
- package/dist/.pikku/scenarios/index.js +1 -1
- package/dist/.pikku/scenarios/pikku-personas.gen.d.ts +4 -0
- package/dist/.pikku/scenarios/pikku-personas.gen.js +1 -1
- package/dist/.pikku/scenarios/pikku-scenario-functions-meta.gen.js +1 -1
- package/dist/.pikku/scenarios/pikku-scenario-functions.gen.d.ts +1 -1
- package/dist/.pikku/scenarios/pikku-scenario-types.gen.d.ts +13 -1
- package/dist/.pikku/scenarios/pikku-scenario-types.gen.js +4 -0
- package/dist/.pikku/scenarios/pikku-scenario-wirings-meta.gen.js +1 -1
- package/dist/.pikku/scenarios/pikku-scenario-wirings.gen.d.ts +1 -1
- package/dist/.pikku/scenarios/schemas/register.gen.d.ts +1 -1
- package/dist/.pikku/scenarios/schemas/register.gen.js +1 -1
- package/dist/.pikku/scheduler/index.d.ts +1 -1
- package/dist/.pikku/scheduler/index.js +1 -1
- package/dist/.pikku/scheduler/pikku-scheduler-types.gen.d.ts +3 -1
- package/dist/.pikku/scheduler/pikku-scheduler-types.gen.js +3 -1
- package/dist/.pikku/schemas/register.gen.js +5 -5
- package/dist/.pikku/schemas/schemas/DocInput.schema.json +1 -0
- package/dist/.pikku/schemas/schemas/DocOutput.schema.json +1 -0
- package/dist/.pikku/schemas/schemas/FabricDeployApplyInput.schema.json +1 -1
- package/dist/.pikku/schemas/schemas/FabricDeployApplyOutput.schema.json +1 -1
- package/dist/.pikku/schemas/schemas/FabricSecretsRotateOutput.schema.json +1 -1
- package/dist/.pikku/schemas/schemas/PikkuSkillsInstallInput.schema.json +1 -1
- package/dist/.pikku/scopes/index.d.ts +1 -1
- package/dist/.pikku/scopes/index.js +1 -1
- package/dist/.pikku/scopes/pikku-personas.gen.js +1 -1
- package/dist/.pikku/scopes/pikku-roles.gen.d.ts +1 -1
- package/dist/.pikku/scopes/pikku-scope-types.gen.d.ts +1 -1
- package/dist/.pikku/scopes/pikku-scope-types.gen.js +1 -1
- package/dist/.pikku/scopes/pikku-scopes.gen.d.ts +1 -1
- package/dist/.pikku/secrets/index.d.ts +1 -1
- package/dist/.pikku/secrets/index.js +1 -1
- package/dist/.pikku/secrets/pikku-secret-types.gen.d.ts +1 -1
- package/dist/.pikku/secrets/pikku-secret-types.gen.js +1 -1
- package/dist/.pikku/secrets/pikku-secrets.gen.d.ts +9 -1
- package/dist/.pikku/secrets/pikku-secrets.gen.js +5 -1
- package/dist/.pikku/services/pikku-meta-service.gen.d.ts +1 -1
- package/dist/.pikku/services/pikku-meta-service.gen.js +1 -1
- package/dist/.pikku/setup/index.d.ts +1 -1
- package/dist/.pikku/setup/index.js +1 -1
- package/dist/.pikku/setup/pikku-setup-types.gen.d.ts +7 -19
- package/dist/.pikku/setup/pikku-setup-types.gen.js +11 -19
- package/dist/.pikku/trigger/index.d.ts +1 -1
- package/dist/.pikku/trigger/index.js +1 -1
- package/dist/.pikku/trigger/pikku-trigger-types.gen.d.ts +5 -1
- package/dist/.pikku/trigger/pikku-trigger-types.gen.js +5 -1
- package/dist/.pikku/variables/index.d.ts +1 -1
- package/dist/.pikku/variables/index.js +1 -1
- package/dist/.pikku/variables/pikku-variable-types.gen.d.ts +1 -1
- package/dist/.pikku/variables/pikku-variable-types.gen.js +1 -1
- package/dist/.pikku/variables/pikku-variables.gen.d.ts +9 -1
- package/dist/.pikku/variables/pikku-variables.gen.js +5 -1
- package/dist/.pikku/workflow/index.d.ts +1 -1
- package/dist/.pikku/workflow/index.js +1 -1
- package/dist/.pikku/workflow/pikku-workflow-types.gen.d.ts +29 -1
- package/dist/.pikku/workflow/pikku-workflow-types.gen.js +8 -1
- package/dist/.pikku/workflow/pikku-workflow-wirings-meta.gen.js +1 -1
- package/dist/.pikku/workflow/pikku-workflow-wirings.gen.js +1 -1
- package/dist/bin/pikku-bin.mjs +2 -2
- package/dist/src/cli.wiring.js +21 -0
- package/dist/src/deploy/analyzer/analyzer.d.ts +1 -0
- package/dist/src/deploy/analyzer/analyzer.js +8 -6
- package/dist/src/deploy/build-pipeline.d.ts +1 -0
- package/dist/src/deploy/build-pipeline.js +1 -0
- package/dist/src/fabric/fabric-commands.d.ts +170 -54
- package/dist/src/fabric/fabric-commands.js +25 -20
- package/dist/src/fabric/functions/deploy.function.d.ts +246 -67
- package/dist/src/fabric/functions/deploy.function.js +316 -69
- package/dist/src/fabric/functions/init.function.js +1 -0
- package/dist/src/fabric/functions/link.function.js +4 -1
- package/dist/src/fabric/functions/secrets-rotate.function.d.ts +4 -4
- package/dist/src/fabric/functions/secrets-rotate.function.js +2 -2
- package/dist/src/fabric/functions/validate.function.js +123 -0
- package/dist/src/fabric/lib/deployment.d.ts +107 -0
- package/dist/src/fabric/lib/deployment.js +264 -0
- package/dist/src/fabric/sdk/pikku-fetch.gen.d.ts +1 -1
- package/dist/src/fabric/sdk/pikku-fetch.gen.js +1 -1
- package/dist/src/fabric/sdk/pikku-rpc.gen.d.ts +4 -1
- package/dist/src/fabric/sdk/pikku-rpc.gen.js +4 -1
- package/dist/src/functions/commands/deploy-apply.js +1 -0
- package/dist/src/functions/commands/deploy-plan.js +1 -0
- package/dist/src/functions/commands/doc.d.ts +22 -0
- package/dist/src/functions/commands/doc.js +25 -0
- package/dist/src/functions/commands/skills.d.ts +3 -0
- package/dist/src/functions/commands/skills.js +4 -2
- package/dist/src/functions/runtimes/tanstack-start/serialize-tanstack-start-shim.d.ts +3 -2
- package/dist/src/functions/runtimes/tanstack-start/serialize-tanstack-start-shim.js +33 -10
- package/dist/src/functions/surface/build-surface-doc.d.ts +8 -1
- package/dist/src/functions/surface/build-surface-doc.js +21 -8
- package/dist/src/functions/surface/collect-snippets.d.ts +13 -0
- package/dist/src/functions/surface/collect-snippets.js +83 -0
- package/dist/src/functions/surface/collect-surface.d.ts +27 -1
- package/dist/src/functions/surface/collect-surface.js +323 -13
- package/dist/src/functions/surface/generate-surface-doc.js +40 -5
- package/dist/src/functions/surface/render-surface-doc.d.ts +12 -0
- package/dist/src/functions/surface/render-surface-doc.js +209 -0
- package/dist/src/functions/surface/surface-doc.types.d.ts +15 -2
- package/dist/src/functions/surface/surface-editorial.d.ts +6 -0
- package/dist/src/functions/surface/surface-editorial.js +22 -1
- package/dist/src/functions/wirings/agent/serialize-agent-types.js +41 -0
- package/dist/src/functions/wirings/agent/serialize-public-agent.js +1 -1
- package/dist/src/functions/wirings/auth/serialize-auth-guards.js +1 -18
- package/dist/src/functions/wirings/auth/serialize-auth-types.js +7 -0
- package/dist/src/functions/wirings/channels/serialize-channel-types.js +9 -17
- package/dist/src/functions/wirings/cli/serialize-cli-types.js +7 -6
- package/dist/src/functions/wirings/functions/pikku-command-function-types-split.js +1 -1
- package/dist/src/functions/wirings/functions/serialize-addon-refs.js +12 -0
- package/dist/src/functions/wirings/functions/serialize-function-types.js +23 -36
- package/dist/src/functions/wirings/http/serialize-http-types.js +6 -0
- package/dist/src/functions/wirings/mcp/serialize-mcp-types.js +10 -0
- package/dist/src/functions/wirings/middleware/serialize-middleware-types.js +32 -23
- package/dist/src/functions/wirings/queue/serialize-queue-types.js +2 -0
- package/dist/src/functions/wirings/scheduler/serialize-scheduler-types.js +2 -0
- package/dist/src/functions/wirings/secrets/serialize-secrets-types.js +8 -0
- package/dist/src/functions/wirings/setup/serialize-setup-types.d.ts +1 -1
- package/dist/src/functions/wirings/setup/serialize-setup-types.js +19 -19
- package/dist/src/functions/wirings/triggers/serialize-trigger-types.js +4 -0
- package/dist/src/functions/wirings/variables/serialize-variables-types.js +8 -0
- package/dist/src/functions/wirings/workflow/serialize-personas.js +4 -0
- package/dist/src/functions/wirings/workflow/serialize-scenario-types.js +12 -0
- package/dist/src/functions/wirings/workflow/serialize-workflow-types.js +28 -0
- package/dist/src/services.js +8 -1
- package/dist/src/utils/assert-single-core-version.js +63 -11
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +9 -7
- package/snippets-meta.json +131 -0
- package/snippets.json +131 -0
- package/surface.json +7393 -458
- package/console-app/assets/architecture-TIHT7OUA-DgRqRaqo.js +0 -1
- package/console-app/assets/channel-37lNznWk.js +0 -1
- package/console-app/assets/chunk-XXDRQBXY-B1prMCvW.js +0 -1
- package/console-app/assets/classDiagram-JCYQIIEL-BDCX9ZLm.js +0 -1
- package/console-app/assets/classDiagram-v2-OCEON4UE-BDCX9ZLm.js +0 -1
- package/console-app/assets/eventmodeling-45OFAUF4-BarrKD55.js +0 -1
- package/console-app/assets/flowDiagram-UKHOOZJN-B6RiRjcD.js +0 -1
- package/console-app/assets/railroad-abnf-AHOZXSZD-lmUjOixE.js +0 -1
- package/console-app/assets/railroad-ebnf-EBAXGLYW-ZjdA7OHp.js +0 -1
- package/console-app/assets/railroad-peg-LSFZ7HO6-VHSlvwI8.js +0 -1
- package/console-app/assets/stateDiagram-v2-UEYNNEHI-BSXWhCvf.js +0 -1
- package/console-app/assets/swimlanesDiagram-ULZ7WXOC-BUdsPDKC.js +0 -8
- package/dist/.pikku/schemas/schemas/FabricDeployPlanInput.schema.json +0 -1
- package/dist/.pikku/schemas/schemas/FabricDeployPlanOutput.schema.json +0 -1
package/snippets.json
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
{
|
|
2
|
+
"addChannelMiddleware": "addChannelMiddleware('orders', [tagChannelEvents('order-status')])",
|
|
3
|
+
"addError": "export class OutOfStockError extends Error {}\n\naddError(OutOfStockError, {\n status: 409,\n message: 'That item is out of stock',\n})",
|
|
4
|
+
"addToBasket": "export const addToBasket = pikkuSessionlessFunc({\n expose: true,\n description:\n 'Add an item to a basket, or increase its quantity if already present.',\n input: AddToBasketInput,\n func: async ({ kysely }, { basketId, itemId, quantity }) => {\n const item = await kysely\n .selectFrom('item')\n .select(['stock', 'isActive'])\n .where('itemId', '=', itemId)\n .executeTakeFirst()\n\n if (!item || !item.isActive) throw new Error('Item not available')\n\n const existing = await kysely\n .selectFrom('basketItem')\n .select(['basketItemId', 'quantity'])\n .where('basketId', '=', basketId)\n .where('itemId', '=', itemId)\n .executeTakeFirst()\n\n if (existing) {\n const newQty = existing.quantity + quantity\n if (newQty > item.stock) throw new Error('Not enough stock')\n await kysely\n .updateTable('basketItem')\n .set({ quantity: newQty })\n .where('basketItemId', '=', existing.basketItemId)\n .execute()\n } else {\n if (quantity > item.stock) throw new Error('Not enough stock')\n await kysely\n .insertInto('basketItem')\n .values({ basketItemId: randomUUID(), basketId, itemId, quantity })\n .execute()\n }\n\n await kysely\n .updateTable('basket')\n .set({ updatedAt: new Date().toISOString() })\n .where('basketId', '=', basketId)\n .execute()\n },\n})",
|
|
5
|
+
"addonStep": "/**\n * Stripe telling us the card cleared. `addon` names the wiring it belongs to —\n * the same `stripe` that `wireAddon` declares — so the step is filed under the\n * service it speaks for rather than under the shop.\n *\n * This arranges, it does not assert: a scenario uses it to get an order into\n * the paid state without a real card, and checks the consequences separately.\n */\nexport const stripeReportsPayment = pikkuAddonScenarioStep<\n { orderId: string; paymentIntentId?: string },\n { applied: boolean }\n>({\n addon: 'stripe',\n name: 'stripeReportsPayment',\n description: 'delivers a payment_intent.succeeded event for an order',\n template: 'Stripe confirms payment for {orderId}',\n func: async (_services, { orderId, paymentIntentId }, { rpc }) => {\n return rpc.invoke('applyStripeEvent', {\n type: 'payment_intent.succeeded',\n data: {\n object: {\n id: paymentIntentId ?? `pi_${orderId}`,\n metadata: { orderId },\n },\n },\n })\n },\n})",
|
|
6
|
+
"addonWiring": "wireAddon({\n name: 'stripe',\n package: '@pikku/addon-stripe',\n scopes: ['payments:charge'],\n})",
|
|
7
|
+
"agentApproval": "// The ops agent can cancel an order, so its tool calls wait for a human. These\n// two routes are the human's side of that pause.\nwireHTTP({\n method: 'post',\n route: '/agents/ops/approve',\n func: agentApprove('opsAgent'),\n auth: true,\n})\n\nwireHTTP({\n method: 'post',\n route: '/agents/ops/resume',\n func: agentResume(),\n auth: true,\n})",
|
|
8
|
+
"agentJudge": "export const answersTheShopper = pikkuAgentJudge({\n name: 'answersTheShopper',\n description: 'Does the answer help someone trying to buy something',\n model: 'openai/o4-mini',\n goal: [\n 'Grade how well the answer addresses what the shopper asked.',\n 'Score 1 when it names the item, price or availability they asked about.',\n 'Score 0 when it talks about the catalogue in general instead.',\n ].join(' '),\n sampleRate: 0,\n})",
|
|
9
|
+
"agentMiddleware": "export const countAgentCharacters = pikkuAgentMiddleware<{\n charCount: number\n}>({\n modifyInput: async ({ logger }, { messages, instructions }) => {\n logger.info({ event: 'agent_input', messages: messages.length })\n return { messages, instructions }\n },\n modifyOutputStream: async (_services, { event, state }) => {\n if (event.type === 'text-delta') {\n state.charCount = (state.charCount ?? 0) + event.text.length\n }\n return event\n },\n})",
|
|
10
|
+
"agentScorer": "export const namesAProduct = pikkuAgentScorer({\n name: 'namesAProduct',\n description: 'An answer that names an actual item beats one that hedges',\n sampleRate: 0.25,\n score: ({ toolCalls }) => ({\n score: toolCalls.some((call) => call.name.startsWith('listItems')) ? 1 : 0,\n reason: `The run made ${toolCalls.length} tool call(s).`,\n }),\n})",
|
|
11
|
+
"aiAgent": "export const shopAssistant = pikkuAgent({\n name: 'shop-assistant',\n description: 'Helps customers browse the catalogue and manage their basket.',\n goal: 'Help users find products, manage their basket, and answer questions about shop items.',\n model: 'openai/gpt-4o-mini',\n tools: [\n listCategories,\n listItems,\n getItem,\n getBasket,\n addToBasket,\n removeFromBasket,\n ],\n memory: { storage: 'aiStorage', lastMessages: 20 },\n maxSteps: 10,\n channelMiddleware: [traceAgentStream],\n agentMiddleware: [countAgentCharacters],\n})",
|
|
12
|
+
"aiAgentInvoke": "// Wire the agent as a standard HTTP endpoint — non-streaming, returns the full response.\nwireHTTP({\n method: 'post',\n route: '/agents/shop',\n func: agent('shopAssistant'),\n auth: true,\n})",
|
|
13
|
+
"aiAgentInvokeClient": "export async function agentExamples() {\n pikkuRPC.setServerUrl('http://localhost:4000')\n pikkuRPC.setAuthorizationJWT('user-jwt-token')\n\n const result = await pikkuRPC.agent.run('shopAssistant', {\n message: 'What USB hubs do you have under £50?',\n threadId: 'thread-123',\n resourceId: 'user-456',\n })\n console.log(result.result, result.usage)\n return result\n}",
|
|
14
|
+
"aiAgentStream": "// Streaming endpoint — sends text-delta, tool-call, and usage events as they arrive.\nwireHTTP({\n method: 'post',\n route: '/agents/shop/stream',\n func: agentStream('shopAssistant'),\n auth: true,\n})",
|
|
15
|
+
"aiAgentStreamClient": "export async function agentStreamExamples() {\n pikkuRPC.setServerUrl('http://localhost:4000')\n pikkuRPC.setAuthorizationJWT('user-jwt-token')\n\n const stream = await pikkuRPC.agent.stream('shopAssistant', {\n message: 'Add the cheapest USB hub to my basket',\n threadId: 'thread-123',\n resourceId: 'user-456',\n })\n return stream\n}",
|
|
16
|
+
"auditDispatch": "await audit?.audit({\n type: 'order.created',\n source: 'explicit',\n occurredAt: new Date().toISOString(),\n metadata: { orderId, userId },\n})",
|
|
17
|
+
"betterAuthConfig": "export const auth = pikkuBetterAuth(\n async ({ kysely, secrets, variables, emailService }) => {\n // `.reveal()` at the sink, not earlier: getSecret hands back a nominal\n // SecretValue that no concretely-typed parameter accepts, so every disclosure\n // is one greppable call. Better Auth wants the raw string, and this is where\n // it stops being a secret in the type system.\n const BETTER_AUTH_SECRET = (\n await secrets.getSecret('BETTER_AUTH_SECRET')\n ).reveal()\n // Genuinely optional: unset simply disables /api/auth/sign-in/actor (scenarios\n // off for this deployment) — the actor plugin refuses all sign-ins\n // without it.\n const SCENARIO_ACTOR_SECRET = await secrets\n .getSecret('SCENARIO_ACTOR_SECRET')\n .then((value) => value?.reveal())\n .catch(() => undefined)\n // Fabric operator admin: the RSA public key the control plane's token is\n // verified against. The Fabric deployer pushes FABRIC_AUTH_PUBLIC_KEY onto\n // every stage; locally it's simply absent, which disables /sign-in/fabric.\n // Asymmetric — the app verifies, it can never forge an operator login.\n const FABRIC_AUTH_PUBLIC_KEY = await variables.get('FABRIC_AUTH_PUBLIC_KEY')\n\n return betterAuth({\n secret: BETTER_AUTH_SECRET,\n database: { db: kysely, type: 'sqlite' },\n emailAndPassword: {\n enabled: true,\n // Without this, `requestPasswordReset` succeeds on the client and silently\n // sends nothing — the \"Forgot password?\" flow looks wired and dead-ends.\n // Better Auth builds `url` from its baseURL + the client's redirectTo, so\n // the app only supplies the message. Errors are logged, never swallowed:\n // a reset the user never receives must be visible in the logs.\n sendResetPassword: async ({ user, url }) => {\n await emailService.send({\n to: user.email,\n template: {\n name: 'reset-password',\n data: { email: user.email, resetUrl: url },\n },\n })\n },\n },\n // Stateless session: CLI splits out betterAuthStatelessSession so non-auth\n // units verify the signed cookie instead of bundling better-auth. pikku #737.\n session: { cookieCache: { enabled: true } },\n advanced: { database: { generateId: 'uuid' } },\n // Scenario actors: synthetic users (user.actor = true, see\n // db/sqlite/0002-user-actor.sql) signed in by pikkuScenario via\n // POST /api/auth/sign-in/actor { email, secret }. Never signs in real users.\n //\n // admin(): exposes /api/auth/admin/* (listUsers, setRole, impersonateUser,\n // …) so an app admin can list and \"view as\" their end-users — this is what\n // the Fabric console's Users tab drives. Adds role/banned/impersonatedBy\n // columns (see db/sqlite/0003-admin.sql). No user is an admin by default:\n // grant it by setting a user's `role` to 'admin' (or pass\n // `adminUserIds: [...]` here) — the admin API refuses non-admins.\n //\n // fabric(): exposes /api/auth/sign-in/fabric — the Fabric control plane\n // mints a short-lived RS256 token and signs in as a synthetic `fabric: true`\n // admin operator (db/sqlite/0004-fabric.sql), so the console Users tab can\n // list/impersonate real users without the operator being one of them. It\n // pairs with admin() and verifies against FABRIC_AUTH_PUBLIC_KEY; missing\n // key disables the endpoint.\n plugins: [\n actor({ secret: SCENARIO_ACTOR_SECRET }),\n admin(),\n fabric({ publicKey: FABRIC_AUTH_PUBLIC_KEY }),\n ],\n })\n }\n)",
|
|
18
|
+
"cancelOrder": "export const cancelOrder = pikkuFunc({\n expose: true,\n description: 'Cancel a pending order and restore stock.',\n input: CancelOrderInput,\n // Authorization is declared, not written. `scopes` is what the role\n // entitles the caller to and is AND-ed; `permissions` is what is true of\n // this particular row and is OR-ed across keys. Both run before the body,\n // so the function below only ever sees a caller who is allowed to be there.\n //\n // The `support` role holds `orders:cancel` without owning the order, so it\n // passes on the scope; a customer passes on ownership. Neither case needs a\n // branch in the body.\n scopes: ['orders:cancel'],\n permissions: {\n owner: isOrderOwner,\n },\n // `node` describes this function as a step someone can drop into a visual\n // flow: what to call it, where it files, and whether it starts a flow\n // ('trigger'), continues one ('action') or ends it ('end'). Without it the\n // function still works everywhere else — it just does not appear as a node.\n node: {\n displayName: 'Cancel Order',\n category: 'Orders',\n type: 'action',\n },\n func: async ({ kysely, queueService }, { orderId }, { session }) => {\n const order = await kysely\n .selectFrom('order')\n .select(['orderId', 'userId', 'status'])\n .where('orderId', '=', orderId)\n .executeTakeFirst()\n\n if (!order) throw new Error('Order not found')\n if (!['pending', 'paid'].includes(order.status)) {\n throw new Error(`Cannot cancel order in status: ${order.status}`)\n }\n\n // Restore stock\n const items = await kysely\n .selectFrom('orderItem')\n .select(['itemId', 'quantity'])\n .where('orderId', '=', orderId)\n .execute()\n\n const now = new Date().toISOString()\n for (const item of items) {\n await kysely\n .updateTable('item')\n .set((eb) => ({\n stock: eb('stock', '+', item.quantity),\n updatedAt: now,\n }))\n .where('itemId', '=', item.itemId)\n .execute()\n }\n\n await kysely\n .updateTable('order')\n .set({ status: 'cancelled', updatedAt: now })\n .where('orderId', '=', orderId)\n .execute()\n\n await queueService.add('audit-event', {\n entityType: 'order',\n entityId: orderId,\n action: 'cancelled',\n actorId: session.userId,\n })\n },\n})",
|
|
19
|
+
"channelLifecycle": "export const onConnect = pikkuChannelConnectionFunc(\n async ({ logger }, _, { channel }) => {\n logger.info({ event: 'ws_connected', channelId: channel.channelId })\n }\n)\n\nexport const onDisconnect = pikkuChannelDisconnectionFunc(\n async ({ logger }, _, { channel }) => {\n logger.info({ event: 'ws_disconnected', channelId: channel.channelId })\n }\n)",
|
|
20
|
+
"channelMiddleware": "export const traceAgentStream = pikkuChannelMiddleware<any, AgentStreamEvent>(\n async ({ logger }, event, next) => {\n logger.debug({ event: 'agent_stream', type: event.type })\n await next(event)\n }\n)",
|
|
21
|
+
"channelMiddlewareFactory": "export const tagChannelEvents = pikkuChannelMiddlewareFactory(\n (channelName: string) =>\n async ({ logger }, event, next) => {\n logger.debug({ event: 'channel_event', channel: channelName })\n await next(event)\n }\n)",
|
|
22
|
+
"channelPubSub": "// Publish from any HTTP handler to all WebSocket clients subscribed to that order.\n// @coverage-unreachable the server-side push half of the order channel; it runs when the app pushes, not when anything calls it\nexport const notifyOrderShipped = pikkuFunc<{ orderId: string }, void>({\n func: async ({ eventHub, kysely }, { orderId }) => {\n await kysely\n .updateTable('order')\n .set({ status: 'shipped', updatedAt: new Date().toISOString() })\n .where('orderId', '=', orderId)\n .execute()\n\n await eventHub?.publish(`order:${orderId}`, null, {\n orderId,\n status: 'shipped',\n })\n },\n})",
|
|
23
|
+
"channelRoutes": "type: defineChannelRoutes({\n subscribe: subscribeToOrder,\n unsubscribe: unsubscribeFromOrder,\n}),",
|
|
24
|
+
"channelWiring": "wireChannel({\n name: 'order-status',\n route: '/orders/status',\n auth: true,\n onConnect,\n onDisconnect,\n onMessageWiring: {\n type: defineChannelRoutes({\n subscribe: subscribeToOrder,\n unsubscribe: unsubscribeFromOrder,\n }),\n },\n})",
|
|
25
|
+
"checkoutWorkflow": "// The workflow body is the plan, not the work: each `workflow.do` names a step\n// and the function that performs it. Pikku records every step in a durable log,\n// so a crash mid-payment resumes at the step that failed rather than charging\n// the card twice.\nexport const checkoutWorkflow = pikkuWorkflowFunc<\n {\n basketId: string\n userId: string\n shippingAddress: ShippingAddress\n cardToken?: string\n },\n { orderId: string; status: 'paid' | 'payment_failed'; totalCents: number }\n>({\n expose: true,\n func: async (_services, data, { workflow }) => {\n const basket = await workflow.do('Validate basket', 'validateBasket', {\n basketId: data.basketId,\n })\n\n const order = await workflow.do('Create order', 'createOrderRecord', {\n userId: data.userId,\n totalCents: basket.totalCents,\n shippingAddress: data.shippingAddress,\n items: basket.items,\n })\n\n // Safely retried if the payment provider times out.\n const payment = await workflow.do('Process payment', 'chargeCard', {\n orderId: order.orderId,\n totalCents: basket.totalCents,\n cardToken: data.cardToken,\n })\n\n const status =\n payment.status === 'succeeded'\n ? ('paid' as const)\n : ('payment_failed' as const)\n\n await workflow.do('Finalize', 'finalizeOrder', {\n orderId: order.orderId,\n basketId: data.basketId,\n userId: data.userId,\n status,\n })\n\n return { orderId: order.orderId, status, totalCents: basket.totalCents }\n },\n})",
|
|
26
|
+
"cleanupAbandonedBaskets": "export const cleanupAbandonedBaskets = pikkuVoidFunc({\n // Exposed so an operator can run the sweep on demand and a scenario can\n // prove it still works. A job reachable only by its schedule is a job\n // nobody can test and nobody can force when it matters.\n expose: true,\n description: 'Cron job: remove anonymous baskets older than 7 days.',\n func: async ({ kysely, logger }) => {\n const cutoff = new Date()\n cutoff.setDate(cutoff.getDate() - 7)\n\n const result = await kysely\n .deleteFrom('basket')\n .where('userId', 'is', null)\n .where('updatedAt', '<', cutoff.toISOString())\n .executeTakeFirst()\n\n logger.info({\n event: 'cleanup_abandoned_baskets',\n deleted: Number(result.numDeletedRows ?? 0),\n })\n },\n})",
|
|
27
|
+
"cleanupFunction": "export const cleanupAbandonedBaskets = pikkuVoidFunc({\n // Exposed so an operator can run the sweep on demand and a scenario can\n // prove it still works. A job reachable only by its schedule is a job\n // nobody can test and nobody can force when it matters.\n expose: true,\n description: 'Cron job: remove anonymous baskets older than 7 days.',\n func: async ({ kysely, logger }) => {\n const cutoff = new Date()\n cutoff.setDate(cutoff.getDate() - 7)\n\n const result = await kysely\n .deleteFrom('basket')\n .where('userId', 'is', null)\n .where('updatedAt', '<', cutoff.toISOString())\n .executeTakeFirst()\n\n logger.info({\n event: 'cleanup_abandoned_baskets',\n deleted: Number(result.numDeletedRows ?? 0),\n })\n },\n})",
|
|
28
|
+
"cliCommandContract": "// The command map is a contract of its own, so it can be declared once and\n// wired wherever the program is assembled.\nconst shopCommands = defineCLICommands({\n report: pikkuCLICommand({\n description: 'Generate the daily sales report',\n func: dailySalesReport,\n }),\n cleanup: pikkuCLICommand({\n description: 'Remove baskets abandoned for more than 24 h',\n func: cleanupAbandonedBaskets,\n }),\n items: pikkuCLICommand({\n description: 'List all items in the catalogue',\n func: listItems,\n }),\n})",
|
|
29
|
+
"cliRenderer": "type ItemList = {\n items: Array<{\n itemId: string\n name: string\n priceCents: number\n stock: number\n }>\n}\n\nexport const itemRenderer = pikkuCLIRender<ItemList>((_services, { items }) => {\n const rows = items.map(\n (i) =>\n `${i.itemId.padEnd(14)} ${i.name.padEnd(20)} £${(i.priceCents / 100).toFixed(2).padStart(8)} ${String(i.stock).padStart(5)}`\n )\n console.log(\n ['ID NAME PRICE STOCK', ...rows].join('\\n')\n )\n})",
|
|
30
|
+
"cliSubcommands": "wireCLI({\n program: 'shop',\n commands: {\n report: pikkuCLICommand({\n description: 'Generate the daily sales report',\n func: dailySalesReport,\n }),\n cleanup: pikkuCLICommand({\n description: 'Remove baskets abandoned for more than 24 h',\n func: cleanupAbandonedBaskets,\n }),\n items: pikkuCLICommand({\n description: 'List all items in the catalogue',\n func: listItems,\n }),\n },\n})",
|
|
31
|
+
"cliUsage": "// $ pikku shop report\n// [2026-06-09] Daily sales: 14 orders · £1,240.00 revenue\n//\n// $ pikku shop cleanup\n// Removed 3 abandoned baskets (older than 24 h)\n//\n// $ pikku shop items --categoryId electronics\n// ID NAME PRICE STOCK\n// item-001 USB-C Hub £29.99 42\n// item-002 Mechanical KB £89.99 7",
|
|
32
|
+
"cliWiring": "// The command map is a contract of its own, so it can be declared once and\n// wired wherever the program is assembled.\nconst shopCommands = defineCLICommands({\n report: pikkuCLICommand({\n description: 'Generate the daily sales report',\n func: dailySalesReport,\n }),\n cleanup: pikkuCLICommand({\n description: 'Remove baskets abandoned for more than 24 h',\n func: cleanupAbandonedBaskets,\n }),\n items: pikkuCLICommand({\n description: 'List all items in the catalogue',\n func: listItems,\n }),\n})\n\nwireCLI({ program: 'shop', commands: shopCommands })",
|
|
33
|
+
"converseSteps": "const verdict = await scenario.do(\n 'Shopper chats to the assistant',\n async () =>\n shopper.converse({\n agent: 'shopAssistant',\n task: 'Find a coffee mug in the shop and add one to my basket.',\n evaluate:\n 'The assistant found a mug and confirmed it is in the basket.',\n })\n)\nif (!verdict.passed) {\n logger.error(verdict.transcript.join('\\n'))\n throw new Error(`Assistant flow failed: ${verdict.reasoning}`)\n}\n\n// The verdict is the persona's judgement — follow up with a deterministic\n// check through the same actor.\nconst basket = await scenario.do(\n 'Basket really has the mug',\n 'getBasket',\n {},\n { actor: shopper }\n)\nif (basket.itemCount === 0)\n throw new Error('Assistant claimed success but the basket is empty')",
|
|
34
|
+
"corsMiddleware": "const corsMiddleware = pikkuMiddleware(\n async ({ variables, ...services }, { http, ...wire }, next) => {\n const middleware = cors({\n origin: await allowedOrigins(variables),\n credentials: true,\n headers: ['Content-Type', 'Authorization', 'X-Auth-Return-Redirect'],\n })\n // Both parameters are destructured and reassembled because the inspector\n // fails the build on an undestructured one (PKU410/PKU411, critical). The\n // delegate reads only `http` and no service at all.\n await middleware({ variables, ...services }, { http, ...wire }, next)\n }\n)\n\naddHTTPMiddleware('*', [corsMiddleware])",
|
|
35
|
+
"createCategory": "export const createCategory = pikkuFunc({\n expose: true,\n description: 'Create a new product category. Admin only.',\n // \"Admin only\" was a sentence in a description, not a rule. The route carried\n // `auth: true`, which means signed in — so any shopper with an account could\n // edit the catalogue. `catalogue:write` was already declared in scopes.ts and\n // already granted to the admin role; nothing was checking it.\n scopes: ['catalogue:write'],\n input: CreateCategoryInput,\n output: CreateCategoryOutput,\n func: async ({ kysely }, { name, slug, description }) => {\n const categoryId = randomUUID()\n await kysely\n .insertInto('category')\n .values({ categoryId, name, slug, description: description ?? null })\n .execute()\n return { categoryId, name, slug }\n },\n})",
|
|
36
|
+
"createItem": "export const createItem = pikkuFunc({\n expose: true,\n description: 'Create a new product. Admin only.',\n // \"Admin only\" was a sentence in a description, not a rule. The route carried\n // `auth: true`, which means signed in — so any shopper with an account could\n // edit the catalogue. `catalogue:write` was already declared in scopes.ts and\n // already granted to the admin role; nothing was checking it.\n scopes: ['catalogue:write'],\n input: CreateItemInput,\n output: CreateItemOutput,\n func: async ({ kysely, queueService }, data) => {\n const itemId = randomUUID()\n const now = new Date().toISOString()\n\n await kysely\n .insertInto('item')\n .values({\n itemId,\n categoryId: data.categoryId,\n name: data.name,\n slug: data.slug,\n description: data.description ?? null,\n priceCents: data.priceCents,\n stock: data.stock,\n imageUrl: data.imageUrl ?? null,\n createdAt: now,\n updatedAt: now,\n })\n .execute()\n\n await queueService.add('audit-event', {\n entityType: 'item',\n entityId: itemId,\n action: 'created',\n })\n\n return {\n itemId,\n name: data.name,\n slug: data.slug,\n priceCents: data.priceCents,\n stock: data.stock,\n }\n },\n})",
|
|
37
|
+
"createOrder": "export const createOrder = pikkuFunc({\n expose: true,\n description:\n 'Create an order from a basket and charge via the fake payment provider.',\n input: CreateOrderInput,\n output: CreateOrderOutput,\n func: async (\n { kysely, queueService, audit },\n { basketId, shippingAddress, cardToken },\n { session, rpc }\n ) => {\n const userId = session.userId\n\n // Load basket items with current prices\n const basketItems = await kysely\n .selectFrom('basketItem')\n .innerJoin('item', 'item.itemId', 'basketItem.itemId')\n .select([\n 'basketItem.itemId',\n 'basketItem.quantity',\n 'item.priceCents',\n 'item.stock',\n 'item.name',\n ])\n .where('basketItem.basketId', '=', basketId)\n .execute()\n\n if (basketItems.length === 0) throw new Error('Basket is empty')\n\n // Validate stock\n for (const bi of basketItems) {\n if (bi.quantity > bi.stock)\n throw new Error(`Insufficient stock for \"${bi.name}\"`)\n }\n\n const totalCents = basketItems.reduce(\n (sum, bi) => sum + bi.priceCents * bi.quantity,\n 0\n )\n const orderId = randomUUID()\n const now = new Date().toISOString()\n\n // Create order\n await kysely\n .insertInto('order')\n .values({\n orderId,\n userId,\n status: 'pending',\n totalCents,\n shippingAddress: JSON.stringify(shippingAddress),\n createdAt: now,\n updatedAt: now,\n })\n .execute()\n\n // Create order items\n await kysely\n .insertInto('orderItem')\n .values(\n basketItems.map((bi) => ({\n orderItemId: randomUUID(),\n orderId,\n itemId: bi.itemId,\n quantity: bi.quantity,\n unitPriceCents: bi.priceCents,\n }))\n )\n .execute()\n\n // Deduct stock\n for (const bi of basketItems) {\n await kysely\n .updateTable('item')\n .set({ stock: bi.stock - bi.quantity, updatedAt: now })\n .where('itemId', '=', bi.itemId)\n .execute()\n }\n\n // Process payment through Stripe, the same way the checkout workflow does.\n // Both used to call a fake service that succeeded unless the card token\n // ended in '0000', so neither path had ever met a payment provider.\n const paymentId = randomUUID()\n const paymentResult = await rpc.invoke('chargeCard', {\n orderId,\n totalCents,\n ...(cardToken ? { cardToken } : {}),\n })\n\n await kysely\n .insertInto('payment')\n .values({\n paymentId,\n orderId,\n amountCents: totalCents,\n provider: 'fake',\n status: paymentResult.status,\n providerRef:\n paymentResult.status === 'succeeded'\n ? paymentResult.providerRef\n : null,\n createdAt: now,\n })\n .execute()\n\n const orderStatus =\n paymentResult.status === 'succeeded' ? 'paid' : 'payment_failed'\n await kysely\n .updateTable('order')\n .set({ status: orderStatus, updatedAt: now })\n .where('orderId', '=', orderId)\n .execute()\n\n if (paymentResult.status === 'succeeded') {\n // Clear basket and enqueue confirmation email\n await kysely\n .deleteFrom('basketItem')\n .where('basketId', '=', basketId)\n .execute()\n await queueService.add('send-order-confirmation', { orderId, userId })\n await audit?.audit({\n type: 'order.created',\n source: 'explicit',\n occurredAt: new Date().toISOString(),\n metadata: { orderId, userId },\n })\n }\n\n return { orderId, status: orderStatus, totalCents }\n },\n})",
|
|
38
|
+
"cronMiddleware": "// Middleware wraps every run of the tasks it is wired to — here, to record how\n// long each run took.\nconst timingMiddleware = pikkuMiddleware(async ({ logger }, _data, next) => {\n const start = Date.now()\n await next()\n logger.info({ event: 'scheduler_timing', ms: Date.now() - start })\n})\n\nwireScheduler({\n name: 'dailySalesReport',\n schedule: '0 6 * * *', // 06:00 UTC every day\n func: dailySalesReport,\n middleware: [timingMiddleware],\n})",
|
|
39
|
+
"cronSkip": "// A scheduled task can skip its own execution by calling wire.scheduledTask.skip().\nexport const conditionalReport = pikkuVoidFunc({\n // Exposed so an operator can run it on demand. A scheduled task that skips\n // itself under some condition is exactly the one you want to be able to\n // trigger by hand, to find out whether it skipped for the right reason.\n expose: true,\n func: async ({ kysely, logger }, _, { scheduledTask }) => {\n const count = await kysely\n .selectFrom('order')\n .where('status', '=', 'pending')\n .select(kysely.fn.countAll<number>().as('n'))\n .executeTakeFirstOrThrow()\n\n if (count.n === 0) {\n scheduledTask?.skip('No pending orders — nothing to report')\n return\n }\n\n logger.info({ event: 'report_run', pendingOrders: count.n })\n },\n})\n\nwireScheduler({\n name: 'conditionalReport',\n schedule: '0 7 * * *',\n func: conditionalReport,\n})",
|
|
40
|
+
"cronWirings": "wireScheduler({\n name: 'dailySalesReport',\n schedule: '0 6 * * *', // 06:00 UTC every day\n func: dailySalesReport,\n middleware: [timingMiddleware],\n})\n\nwireScheduler({\n name: 'cleanupAbandonedBaskets',\n schedule: '0 3 * * *', // 03:00 UTC every day\n func: cleanupAbandonedBaskets,\n})",
|
|
41
|
+
"dailySalesReport": "export const dailySalesReport = pikkuVoidFunc({\n // Exposed so an operator can run the sweep on demand and a scenario can\n // prove it still works. A job reachable only by its schedule is a job\n // nobody can test and nobody can force when it matters.\n expose: true,\n description: 'Cron job: compute and log a daily sales summary.',\n func: async ({ kysely, logger }) => {\n const yesterday = new Date()\n yesterday.setDate(yesterday.getDate() - 1)\n yesterday.setHours(0, 0, 0, 0)\n const dayStart = yesterday.toISOString()\n\n const today = new Date(yesterday)\n today.setDate(today.getDate() + 1)\n const dayEnd = today.toISOString()\n\n const [summary, topItems] = await Promise.all([\n kysely\n .selectFrom('order')\n .select([\n (eb) => eb.fn.countAll<number>().as('orderCount'),\n (eb) => eb.fn.sum<number>('totalCents').as('revenueCents'),\n ])\n .where('status', '=', 'paid')\n .where('createdAt', '>=', dayStart)\n .where('createdAt', '<', dayEnd)\n .executeTakeFirst(),\n kysely\n .selectFrom('orderItem')\n .innerJoin('order', 'order.orderId', 'orderItem.orderId')\n .innerJoin('item', 'item.itemId', 'orderItem.itemId')\n .select([\n 'item.name',\n (eb) => eb.fn.sum<number>('orderItem.quantity').as('unitsSold'),\n ])\n .where('order.status', '=', 'paid')\n .where('order.createdAt', '>=', dayStart)\n .where('order.createdAt', '<', dayEnd)\n .groupBy('orderItem.itemId')\n .orderBy('unitsSold', 'desc')\n .limit(5)\n .execute(),\n ])\n\n logger.info({\n event: 'daily_sales_report',\n date: yesterday.toISOString().split('T')[0],\n orders: Number(summary?.orderCount ?? 0),\n revenueCents: Number(summary?.revenueCents ?? 0),\n topItems,\n })\n },\n})",
|
|
42
|
+
"definePersonas": "definePersonas({\n visitor: {\n // A customer, like the people this app is for. The catalogue happens to be\n // readable without a session, so this is not what makes the smoke gate\n // pass — it is what stops the gate depending on that staying true.\n name: 'Visitor',\n jobTitle: 'Synthetic health-check user',\n roles: ['customer'],\n personality:\n 'Signs in and checks their own session — proves auth end to end',\n account: {},\n },\n shopper: {\n name: 'Susan',\n jobTitle: 'Buys for a small caf\\u00e9',\n description: 'Orders stock every week and watches the margins',\n roles: ['customer'],\n personality:\n 'Hunts cheap deals. Tries three coupon codes before giving up, and abandons a basket if checkout asks for anything unexpected.',\n goals: ['Get the weekly order in under five minutes'],\n disposition: 'careless',\n account: {},\n },\n /**\n * Holds `orders:refund` without owning any order — the seam that makes the\n * scope-versus-permission split worth demonstrating. A scenario run as Priya\n * proves support can act on somebody else's order; one run as Susan proves a\n * customer cannot.\n */\n priya: {\n name: 'Priya from Support',\n jobTitle: 'Customer support',\n description:\n 'Handles refunds and cancellations for other people\\u2019s orders',\n roles: ['support'],\n personality:\n 'Methodical. Reads the order history before touching anything, and says what she changed.',\n goals: ['Resolve the customer\\u2019s problem without escalating'],\n account: {},\n },\n alex: {\n name: 'Alex the Owner',\n jobTitle: 'Shop owner',\n description: 'Runs the shop \\u2014 prices, stock and who works here',\n roles: ['admin'],\n personality:\n 'Impatient. Knows the catalogue by heart and expects bulk actions to exist.',\n goals: ['Keep stock accurate', 'See what sold this week'],\n account: {},\n },\n})",
|
|
43
|
+
"defineRoles": "defineSystemRole({\n customer: {\n displayName: 'Customer',\n description: 'Browse the catalogue, place and track their own orders',\n scopes: [\n 'catalogue:read',\n 'orders:create',\n 'orders:read',\n 'payments:charge',\n ],\n },\n /**\n * Handles orders but cannot change what is for sale. `orders:refund` without\n * `catalogue:write` is the seam worth having in a template: support staff\n * routinely need to undo a payment and routinely should not be able to\n * reprice the shop.\n */\n support: {\n displayName: 'Support',\n description: 'Handle customer orders, including refunds',\n scopes: [\n 'catalogue:read',\n 'orders:read',\n 'orders:cancel',\n 'orders:refund',\n 'payments:manage',\n ],\n },\n /**\n * Names the parents rather than enumerating their children — pikku's\n * parent-grant rule means holding a node grants everything beneath it, so\n * `catalogue` is `catalogue:read` and `catalogue:write` in one word instead\n * of a list that drifts as the tree grows.\n *\n * `pikku:console` comes from `@pikku/addon-console`, not from this app: the\n * console's own surface is the addon's to declare, and the shop composes with\n * it rather than declaring a competing tree.\n */\n admin: {\n displayName: 'Administrator',\n description: 'Everything the shop can do, plus console administration',\n scopes: ['catalogue', 'orders', 'payments', 'reports', 'pikku:console'],\n },\n})",
|
|
44
|
+
"defineScopes": "defineScope({\n catalogue: {\n displayName: 'Catalogue',\n description: 'Browse and manage what is for sale',\n scopes: {\n read: { description: 'Browse items and categories' },\n write: { description: 'Create, edit and withdraw items' },\n },\n },\n orders: {\n displayName: 'Orders',\n description: 'Placing and handling orders',\n scopes: {\n read: { description: 'Read orders' },\n create: { description: 'Place an order' },\n cancel: { description: 'Cancel an order before it ships' },\n refund: { description: 'Refund a paid order' },\n },\n },\n payments: {\n displayName: 'Payments',\n description: 'Reaching the payment provider',\n scopes: {\n charge: { description: 'Create a payment intent for your own order' },\n manage: {\n description: 'Refunds, customers and everything else Stripe offers',\n },\n },\n },\n reports: {\n displayName: 'Reports',\n description: 'Sales and stock reporting',\n scopes: {\n read: { description: 'Read sales and stock reports' },\n },\n },\n})",
|
|
45
|
+
"fetchClient": "export async function shopApiExamples() {\n pikkuFetch.setServerUrl('http://localhost:4000')\n\n const categories = await pikkuFetch.get('/categories')\n const item = await pikkuFetch.get('/items/:itemId', { itemId: 'item-001' })\n\n pikkuFetch.setAuthorizationJWT('user-jwt-token')\n const order = await pikkuFetch.post('/orders', {\n basketId: 'basket-123',\n shippingAddress: {\n line1: '1 High St',\n city: 'London',\n postcode: 'SW1A 1AA',\n country: 'GB',\n },\n })\n\n return { categories, item, order }\n}",
|
|
46
|
+
"funcMultiWire": "// The same function can be wired to multiple transports without any changes.\n// Define once, wire everywhere.\nwireHTTP({ method: 'get', route: '/items/:itemId', func: getItem, auth: false })",
|
|
47
|
+
"funcThreeParams": "// Three parameters, always: services, data, wire.\n// services — your toolbox (db, logger, queues)\n// data — the typed input, wherever it arrived from\n// wire — the protocol context (session, http, channel, rpc)\nexport const getOrder = pikkuFunc({\n expose: true,\n description: 'Get a single order. Users can only access their own orders.',\n input: GetOrderInput,\n output: GetOrderOutput,\n func: async ({ kysely }, { orderId }, { session }) => {\n const order = await kysely\n .selectFrom('order')\n .selectAll()\n .where('orderId', '=', orderId)\n .executeTakeFirst()\n\n if (!order) throw new Error('Order not found')\n if (order.userId !== session.userId && session.role !== 'admin') {\n throw new Error('Forbidden')\n }\n\n const items = await kysely\n .selectFrom('orderItem')\n .innerJoin('item', 'item.itemId', 'orderItem.itemId')\n .select([\n 'orderItem.itemId',\n 'item.name',\n 'orderItem.quantity',\n 'orderItem.unitPriceCents',\n ])\n .where('orderItem.orderId', '=', orderId)\n .execute()\n\n return {\n orderId: order.orderId,\n status: order.status,\n totalCents: order.totalCents,\n // Nullable: a walk-through order placed before an address was captured\n // has none, and returning null beats throwing on a read.\n shippingAddress: order.shippingAddress\n ? JSON.parse(order.shippingAddress)\n : null,\n items: items.map((i) => ({\n ...i,\n lineTotalCents: i.unitPriceCents * i.quantity,\n })),\n createdAt: order.createdAt,\n }\n },\n})",
|
|
48
|
+
"gatewayAdapter": "// A gateway adapter normalizes platform-specific payloads into GatewayInboundMessage.\nconst webChatAdapter: GatewayAdapter = {\n name: 'webchat',\n parse(data: unknown): GatewayInboundMessage | null {\n const msg = data as Record<string, unknown>\n if (!msg.text) return null\n return {\n senderId: String(msg.clientId ?? 'anon'),\n text: String(msg.text),\n raw: data,\n }\n },\n async send(_senderId: string, _message: GatewayOutboundMessage) {\n // Where a real adapter answers the visitor — over the socket for a web chat,\n // via the platform's API for Slack or WhatsApp.\n },\n async init(_onMessage: (msg: GatewayInboundMessage) => Promise<void>) {\n // Where a real adapter subscribes to the platform and calls `onMessage` for\n // each inbound message. Empty here, which is why nothing yet reaches the\n // handler: this is the seam to fill in per platform.\n },\n async close() {},\n}",
|
|
49
|
+
"gatewayWebsocket": "// WebSocket transport — browser clients connect directly.\n// The adapter handles upgrade handshake and binary framing.\nwireGateway({\n name: 'webchat',\n type: 'websocket',\n route: '/chat',\n adapter: webChatAdapter,\n func: handleChatMessage,\n auth: false,\n})",
|
|
50
|
+
"getBasket": "export const getBasket = pikkuSessionlessFunc({\n expose: true,\n description:\n 'Get basket for the current user or session. Creates one if it does not exist.',\n input: GetBasketInput,\n output: GetBasketOutput,\n func: async ({ kysely }, { sessionId }, { session }) => {\n const userId = session?.userId ?? null\n const sid = sessionId ?? null\n\n // Find or create basket\n let basket = userId\n ? await kysely\n .selectFrom('basket')\n .selectAll()\n .where('userId', '=', userId)\n .executeTakeFirst()\n : sid\n ? await kysely\n .selectFrom('basket')\n .selectAll()\n .where('sessionId', '=', sid)\n .executeTakeFirst()\n : null\n\n if (!basket) {\n const basketId = randomUUID()\n const now = new Date().toISOString()\n await kysely\n .insertInto('basket')\n .values({\n basketId,\n userId,\n sessionId: sid,\n createdAt: now,\n updatedAt: now,\n })\n .execute()\n return { basketId, items: [], totalCents: 0, itemCount: 0 }\n }\n\n const rows = await kysely\n .selectFrom('basketItem')\n .innerJoin('item', 'item.itemId', 'basketItem.itemId')\n .select([\n 'basketItem.basketItemId',\n 'basketItem.itemId',\n 'basketItem.quantity',\n 'item.name',\n 'item.slug',\n 'item.priceCents',\n 'item.imageUrl',\n ])\n .where('basketItem.basketId', '=', basket.basketId)\n .execute()\n\n const items = rows.map((r) => ({\n basketItemId: r.basketItemId,\n itemId: r.itemId,\n name: r.name,\n slug: r.slug,\n priceCents: r.priceCents,\n imageUrl: r.imageUrl,\n quantity: r.quantity,\n lineTotalCents: r.priceCents * r.quantity,\n }))\n\n return {\n basketId: basket.basketId,\n items,\n totalCents: items.reduce((sum, i) => sum + i.lineTotalCents, 0),\n itemCount: items.reduce((sum, i) => sum + i.quantity, 0),\n }\n },\n})",
|
|
51
|
+
"getItem": "export const getItem = pikkuSessionlessFunc({\n expose: true,\n description: 'Get a single item by ID.',\n input: GetItemInput,\n output: GetItemOutput,\n func: async ({ kysely }, { itemId }) => {\n const row = await kysely\n .selectFrom('item')\n .innerJoin('category', 'category.categoryId', 'item.categoryId')\n .select([\n 'item.itemId',\n 'item.name',\n 'item.slug',\n 'item.description',\n 'item.priceCents',\n 'item.stock',\n 'item.imageUrl',\n 'item.isActive',\n 'item.createdAt',\n 'item.updatedAt',\n 'category.categoryId',\n 'category.name as categoryName',\n 'category.slug as categorySlug',\n ])\n .where('item.itemId', '=', itemId)\n .executeTakeFirst()\n\n if (!row) throw new Error(`Item not found: ${itemId}`)\n\n return {\n ...row,\n category: {\n categoryId: row.categoryId,\n name: row.categoryName,\n slug: row.categorySlug,\n },\n }\n },\n})",
|
|
52
|
+
"getOrder": "// Three parameters, always: services, data, wire.\n// services — your toolbox (db, logger, queues)\n// data — the typed input, wherever it arrived from\n// wire — the protocol context (session, http, channel, rpc)\nexport const getOrder = pikkuFunc({\n expose: true,\n description: 'Get a single order. Users can only access their own orders.',\n input: GetOrderInput,\n output: GetOrderOutput,\n func: async ({ kysely }, { orderId }, { session }) => {\n const order = await kysely\n .selectFrom('order')\n .selectAll()\n .where('orderId', '=', orderId)\n .executeTakeFirst()\n\n if (!order) throw new Error('Order not found')\n if (order.userId !== session.userId && session.role !== 'admin') {\n throw new Error('Forbidden')\n }\n\n const items = await kysely\n .selectFrom('orderItem')\n .innerJoin('item', 'item.itemId', 'orderItem.itemId')\n .select([\n 'orderItem.itemId',\n 'item.name',\n 'orderItem.quantity',\n 'orderItem.unitPriceCents',\n ])\n .where('orderItem.orderId', '=', orderId)\n .execute()\n\n return {\n orderId: order.orderId,\n status: order.status,\n totalCents: order.totalCents,\n // Nullable: a walk-through order placed before an address was captured\n // has none, and returning null beats throwing on a read.\n shippingAddress: order.shippingAddress\n ? JSON.parse(order.shippingAddress)\n : null,\n items: items.map((i) => ({\n ...i,\n lineTotalCents: i.unitPriceCents * i.quantity,\n })),\n createdAt: order.createdAt,\n }\n },\n})",
|
|
53
|
+
"httpAuthRoute": "// Public route — no auth required\nwireHTTP({ method: 'get', route: '/items', func: listItems, auth: false })\n\n// Protected route — requires a user session\nwireHTTP({ method: 'post', route: '/orders', func: createOrder, auth: true })",
|
|
54
|
+
"httpMiddleware": "// Global middleware — applies to every HTTP route\naddHTTPMiddleware('*', [\n async ({ logger }, data, next) => {\n const start = Date.now()\n await next()\n logger.info({ path: data.http?.request?.path(), ms: Date.now() - start })\n },\n])\n\n// Prefix middleware — applies only to /orders/*\naddHTTPMiddleware('/orders', [\n async (_services, _data, next) => {\n // e.g. rate-limit order creation\n await next()\n },\n])",
|
|
55
|
+
"httpRoutesWiring": "wireHTTPRoutes({ routes: { shop: shopRoutes } })",
|
|
56
|
+
"httpSingleRoute": "// Wire a single route — good for one-offs\nwireHTTP({\n method: 'get',\n route: '/items/:itemId',\n func: getItem,\n auth: false,\n})",
|
|
57
|
+
"isExpectedError": "try {\n await rpc.invoke('onLowStock', {\n itemId: row.itemId,\n name: row.name,\n stock: row.stock,\n })\n} catch (error) {\n // An error pikku knows about carries a status and a message meant for\n // the caller; anything else is a bug and belongs on the floor.\n if (!isExpectedError(error)) throw error\n logger.warn({ event: 'low_stock_alert_failed', itemId: row.itemId })\n}",
|
|
58
|
+
"listCategories": "export const listCategories = pikkuSessionlessFunc({\n expose: true,\n description: 'List all product categories.',\n output: ListCategoriesOutput,\n func: async ({ kysely }) => {\n return kysely\n .selectFrom('category')\n .select(['categoryId', 'name', 'slug', 'description'])\n .orderBy('name')\n .execute()\n },\n})",
|
|
59
|
+
"listFunction": "export const listItemRows = pikkuListFunc<\n { categorySlug: string; inStock: boolean },\n { itemId: string; name: string; priceCents: number; stock: number }\n>({\n expose: true,\n description: 'Catalogue rows in the standard list shape (rows + cursor).',\n func: async ({ kysely }, { limit, search }) => {\n let query = kysely\n .selectFrom('item')\n .innerJoin('category', 'category.categoryId', 'item.categoryId')\n .select(['item.itemId', 'item.name', 'item.priceCents', 'item.stock'])\n .where('item.isActive', '=', 1)\n\n if (search) query = query.where('item.name', 'like', `%${search}%`)\n\n const rows = await query.limit(limit ?? 20).execute()\n\n return { rows, nextCursor: null, totalCount: rows.length }\n },\n})",
|
|
60
|
+
"listItems": "export const listItems = pikkuSessionlessFunc({\n expose: true,\n description: 'List items, optionally filtered by category or search query.',\n input: ListItemsInput,\n output: ListItemsOutput,\n func: async (\n { kysely },\n { categorySlug, search, limit = 20, offset = 0 }\n ) => {\n let query = kysely\n .selectFrom('item')\n .innerJoin('category', 'category.categoryId', 'item.categoryId')\n .select([\n 'item.itemId',\n 'item.name',\n 'item.slug',\n 'item.description',\n 'item.priceCents',\n 'item.stock',\n 'item.imageUrl',\n 'category.categoryId',\n 'category.name as categoryName',\n 'category.slug as categorySlug',\n ])\n .where('item.isActive', '=', 1)\n\n if (categorySlug) {\n query = query.where('category.slug', '=', categorySlug)\n }\n if (search) {\n query = query.where((eb) =>\n eb.or([\n eb('item.name', 'like', `%${search}%`),\n eb('item.description', 'like', `%${search}%`),\n ])\n )\n }\n\n const [rows, countRow] = await Promise.all([\n query.limit(limit).offset(offset).execute(),\n query\n .select((eb) => eb.fn.countAll<number>().as('count'))\n .executeTakeFirst(),\n ])\n\n return {\n items: rows.map((r) => ({\n itemId: r.itemId,\n name: r.name,\n slug: r.slug,\n description: r.description,\n priceCents: r.priceCents,\n stock: r.stock,\n imageUrl: r.imageUrl,\n category: {\n categoryId: r.categoryId,\n name: r.categoryName,\n slug: r.categorySlug,\n },\n })),\n total: Number(countRow?.count ?? 0),\n }\n },\n})",
|
|
61
|
+
"listOrders": "export const listOrders = pikkuFunc({\n expose: true,\n description: 'List orders for the current user.',\n input: ListOrdersInput,\n func: async ({ kysely }, { limit, offset }, { session }) => {\n return kysely\n .selectFrom('order')\n .select(['orderId', 'status', 'totalCents', 'createdAt'])\n .where('userId', '=', session.userId)\n .orderBy('createdAt', 'desc')\n .limit(limit)\n .offset(offset)\n .execute()\n },\n})",
|
|
62
|
+
"lowStockTrigger": "/**\n * The handler behind the `low-stock` trigger.\n *\n * It lives here rather than beside `wireTrigger` because pikku's inspector\n * reads wiring files for wirings only: a file that both defines a function and\n * wires it produces \"metadata not found\" and the wiring is SKIPPED — silently,\n * at startup, with the app otherwise healthy.\n */\nexport const onLowStock = pikkuSessionlessFunc({\n // Exposed so the sweep can invoke it by name and a scenario can prove the\n // alert path works without waiting for stock to actually run down.\n expose: true,\n description:\n 'Trigger: fires when an item stock drops below the configured threshold.',\n input: LowStockPayload,\n func: async ({ logger }, { itemId, name, stock }) => {\n logger.warn({ event: 'low_stock_alert', itemId, name, stock })\n // In production: send Slack notification, create restocking ticket, etc.\n },\n})",
|
|
63
|
+
"machineAuth": "/**\n * The ops integrations have no browser, so they cannot carry the Better Auth\n * cookie the storefront uses. Each of these leaves an existing session alone,\n * so they compose: the first one to recognise the caller wins and the rest\n * fall through.\n */\naddHTTPMiddleware('/rpc', [\n authAPIKey({ source: 'header' }),\n authBearer({\n token: {\n secretId: 'OPS_API_TOKEN',\n userSession: { userId: 'ops-integration' },\n },\n }),\n authCookie({\n name: 'shop-session',\n options: { sameSite: 'lax' },\n expiresIn: { value: 7, unit: 'day' },\n }),\n])",
|
|
64
|
+
"mcpPrompt": "// MCP prompts give AI agents reusable conversation starters.\nexport const productRecommendation = pikkuMCPPromptFunc<{\n category: string\n budget: number\n}>(async ({}, { category, budget }) => {\n return [\n {\n role: 'user' as const,\n content: {\n type: 'text' as const,\n text: `Recommend products in \"${category}\" under £${budget}. List top 3 with prices.`,\n },\n },\n ]\n})\n\nwireMCPPrompt({\n name: 'product_recommendation',\n description:\n 'Generate a product recommendation prompt for a given category and budget',\n func: productRecommendation,\n})",
|
|
65
|
+
"mcpResource": "// MCP resources let AI agents read data by URI template.\nexport const itemResource = pikkuMCPResourceFunc<{ itemId: string }>(\n async ({ kysely }, { itemId }, { mcp }) => {\n const item = await kysely\n .selectFrom('item')\n .selectAll()\n .where('itemId', '=', itemId)\n .executeTakeFirstOrThrow()\n return [{ uri: mcp.uri!, text: JSON.stringify(item) }]\n }\n)\n\nwireMCPResource({\n uri: 'shop://items/{itemId}',\n title: 'Shop Item',\n description: 'Retrieve a single shop item by ID',\n func: itemResource,\n})",
|
|
66
|
+
"mcpSingleTool": "// Any Pikku function becomes an MCP tool — the same implementation already wired to HTTP.\nexport const getItemForAI = pikkuMCPToolFunc<{ itemId: string }>({\n description: 'Retrieve a shop item by its ID',\n func: async (_services, { itemId }, { rpc }) => {\n const result = await rpc.invoke('getItem', { itemId })\n return [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }]\n },\n})",
|
|
67
|
+
"mcpTools": "// Wrap existing Pikku functions as MCP tools via RPC — same implementation, no duplication.\nexport const listCategoriesTool = pikkuMCPToolFunc({\n description: 'List all product categories in the shop',\n func: async (_services, _input, { rpc }) => {\n const result = await rpc.invoke('listCategories')\n return [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }]\n },\n})\n\nexport const listItemsTool = pikkuMCPToolFunc<{\n categorySlug?: string\n search?: string\n limit?: number\n offset?: number\n}>({\n description:\n 'List shop items, optionally filtered by category or search query',\n func: async (\n _services,\n { categorySlug, search, limit = 20, offset = 0 },\n { rpc }\n ) => {\n // Spread the optional filters in only when they were supplied. Passing them\n // as explicit `undefined` fails validation with `Instances of \"undefined\"\n // type are not supported`, so calling this tool without a filter — the\n // ordinary case — was an internal error.\n const result = await rpc.invoke('listItems', {\n ...(categorySlug === undefined ? {} : { categorySlug }),\n ...(search === undefined ? {} : { search }),\n limit,\n offset,\n })\n return [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }]\n },\n})\n\nexport const getItemTool = pikkuMCPToolFunc<{ itemId: string }>({\n description: 'Get full details for a specific shop item by ID',\n func: async (_services, { itemId }, { rpc }) => {\n const result = await rpc.invoke('getItem', { itemId })\n return [{ type: 'text' as const, text: JSON.stringify(result, null, 2) }]\n },\n})",
|
|
68
|
+
"mcpWireObject": "// Inside an MCP tool function, use the mcp wire object for dynamic control.\n/**\n * A mutating tool, and a scope-gated one.\n *\n * It goes through `updateItem` rather than writing to the table directly. That\n * is what makes it safe: `updateItem` requires `catalogue:write`, and since MCP\n * carries the caller's request the scope is checked against the caller's real\n * session — an agent gets exactly the authority the person driving it has.\n *\n * Writing through `kysely` here instead would bypass that check, which is what\n * this tool used to do, back when nothing could authenticate an MCP call and a\n * scope had no session to be checked against.\n */\nexport const updateStockTool = pikkuMCPToolFunc<{\n itemId: string\n stock: number\n}>({\n name: 'update_stock',\n description: 'Update stock level for a shop item',\n func: async (_services, { itemId, stock }, { mcp, rpc }) => {\n await rpc.invoke('updateItem', { itemId, stock })\n\n mcp.sendResourceUpdated(`shop://items/${itemId}`)\n await mcp.enableTools({ add_to_basket: stock > 0 } as any)\n\n return [\n { type: 'text', text: `Stock updated to ${stock} for item ${itemId}` },\n ]\n },\n})",
|
|
69
|
+
"middlewareFactory": "export const auditMiddleware = pikkuMiddlewareFactory(\n (action: string) =>\n async ({ logger }, _data, next) => {\n const start = Date.now()\n const result = await next()\n logger.info({ event: 'audit', action, ms: Date.now() - start })\n return result\n }\n)",
|
|
70
|
+
"orderStatusChannel": "export const subscribeToOrder = pikkuChannelFunc<{ orderId: string }, void>(\n async ({ eventHub }, { orderId }, { channel }) => {\n await eventHub?.subscribe(`order:${orderId}`, channel.channelId)\n }\n)\n\nexport const unsubscribeFromOrder = pikkuChannelFunc<{ orderId: string }, void>(\n async ({ eventHub }, { orderId }, { channel }) => {\n await eventHub?.unsubscribe(`order:${orderId}`, channel.channelId)\n }\n)",
|
|
71
|
+
"paymentTable": "CREATE TABLE IF NOT EXISTS payment (\n payment_id TEXT PRIMARY KEY,\n order_id TEXT NOT NULL REFERENCES \"order\"(order_id),\n amount_cents INTEGER NOT NULL,\n -- 'succeeded' | 'failed'\n status TEXT NOT NULL,\n provider TEXT,\n provider_ref TEXT,\n reason TEXT,\n created_at TEXT NOT NULL DEFAULT (datetime('now'))\n);",
|
|
72
|
+
"permissionFunction": "export const isOrderOwner = pikkuPermission(\n async ({ kysely }, { orderId }: { orderId: string }, { session }) => {\n const order = await kysely\n .selectFrom('order')\n .select('userId')\n .where('orderId', '=', orderId)\n .executeTakeFirst()\n return order?.userId === session?.userId\n }\n)",
|
|
73
|
+
"permissionsCompact": "// scopes: ['orders:cancel'] AND — every scope required\n// permissions: { owner: isOrderOwner } OR — any key may pass",
|
|
74
|
+
"pikkuConfig": "export const createConfig = pikkuConfig(async () => ({\n port: parseInt(process.env.API_PORT || '4003', 10),\n hostname: process.env.HOST || '0.0.0.0',\n}))",
|
|
75
|
+
"platformStep": "/**\n * Shipping is the shop acting on itself: no shopper clicks it, and the order\n * update the customer receives is a consequence rather than a request.\n */\nexport const shipsTheOrder = pikkuPlatformScenarioStep<\n { orderId: string },\n { orderId: string }\n>({\n name: 'shipsTheOrder',\n description:\n 'ships an order, which is what tells the shopper it is on its way',\n template: 'ships order {orderId}',\n func: async (_services, { orderId }, { rpc }) => {\n await rpc.invoke('notifyOrderShipped', { orderId })\n return { orderId }\n },\n})",
|
|
76
|
+
"queueConfig": "// Configure concurrency, retries, and job retention per worker.\nwireQueueWorker({\n name: 'send-order-confirmation',\n func: sendOrderConfirmation,\n config: {\n // Process 5 emails in parallel\n batchSize: 5,\n // Keep last 100 completed jobs for debugging\n removeOnComplete: 100,\n },\n})",
|
|
77
|
+
"queueJobControl": "// The wire object every queue worker is handed: discard the job, or report\n// progress back to whoever is watching it.\nexport const processExport = pikkuSessionlessFunc({\n func: async (\n { kysely, logger },\n { exportId }: { exportId: string },\n { queue }\n ) => {\n const rows = await kysely.selectFrom('order').selectAll().execute()\n\n if (rows.length === 0) {\n // No work — remove from the queue, no retry\n queue?.discard('No orders to export')\n return\n }\n\n for (let i = 0; i < rows.length; i++) {\n // Report 0-100 progress\n await queue?.updateProgress(Math.round((i / rows.length) * 100))\n }\n\n logger.info({ exportId, count: rows.length })\n },\n})",
|
|
78
|
+
"queuePublish": "// Publishing to a queue: `queueService.add(name, payload)`, typed against the\n// queue's declared payload shape.\n//\n// This is the step that actually sends a receipt. The example used to be\n// `placeOrder`, which inserted an order with a zero total and an empty address\n// purely to have something to queue about, and which nothing ever called.\nexport const finalizeOrder = pikkuSessionlessFunc({\n description:\n 'Record the outcome, and on success clear the basket and queue the receipt.',\n func: async (\n { kysely, queueService },\n data: {\n orderId: string\n basketId: string\n userId: string\n status: 'paid' | 'payment_failed'\n }\n ) => {\n await kysely\n .updateTable('order')\n .set({ status: data.status, updatedAt: new Date().toISOString() })\n .where('orderId', '=', data.orderId)\n .execute()\n\n if (data.status === 'paid') {\n await kysely\n .deleteFrom('basketItem')\n .where('basketId', '=', data.basketId)\n .execute()\n await queueService?.add('send-order-confirmation', {\n orderId: data.orderId,\n userId: data.userId,\n })\n }\n },\n})",
|
|
79
|
+
"queueWirings": "wireQueueWorker({\n name: 'send-order-confirmation',\n func: sendOrderConfirmation,\n})\n\nwireQueueWorker({\n name: 'audit-event',\n func: writeAuditEvent,\n})",
|
|
80
|
+
"queueWorkerFunction": "export const sendOrderConfirmation = pikkuSessionlessFunc({\n expose: false,\n description:\n 'Queue consumer: send order confirmation email after a successful payment.',\n input: SendOrderConfirmationInput,\n func: async ({ kysely, logger }, { orderId, userId }) => {\n const [order, user] = await Promise.all([\n kysely\n .selectFrom('order')\n .select(['orderId', 'totalCents', 'status', 'createdAt'])\n .where('orderId', '=', orderId)\n .executeTakeFirst(),\n kysely\n .selectFrom('user')\n .select(['email', 'name'])\n .where('id', '=', userId)\n .executeTakeFirst(),\n ])\n\n if (!order || !user) {\n logger.warn(\n `sendOrderConfirmation: order or user not found (${orderId}, ${userId})`\n )\n return\n }\n\n // In production: call your email service here (SendGrid, Resend, etc.)\n logger.info({\n event: 'order_confirmation_sent',\n to: user.email,\n name: user.name,\n orderId: order.orderId,\n totalCents: order.totalCents,\n })\n },\n})",
|
|
81
|
+
"readFunction": "export const listItems = pikkuSessionlessFunc({\n expose: true,\n description: 'List items, optionally filtered by category or search query.',\n input: ListItemsInput,\n output: ListItemsOutput,\n func: async (\n { kysely },\n { categorySlug, search, limit = 20, offset = 0 }\n ) => {\n let query = kysely\n .selectFrom('item')\n .innerJoin('category', 'category.categoryId', 'item.categoryId')\n .select([\n 'item.itemId',\n 'item.name',\n 'item.slug',\n 'item.description',\n 'item.priceCents',\n 'item.stock',\n 'item.imageUrl',\n 'category.categoryId',\n 'category.name as categoryName',\n 'category.slug as categorySlug',\n ])\n .where('item.isActive', '=', 1)\n\n if (categorySlug) {\n query = query.where('category.slug', '=', categorySlug)\n }\n if (search) {\n query = query.where((eb) =>\n eb.or([\n eb('item.name', 'like', `%${search}%`),\n eb('item.description', 'like', `%${search}%`),\n ])\n )\n }\n\n const [rows, countRow] = await Promise.all([\n query.limit(limit).offset(offset).execute(),\n query\n .select((eb) => eb.fn.countAll<number>().as('count'))\n .executeTakeFirst(),\n ])\n\n return {\n items: rows.map((r) => ({\n itemId: r.itemId,\n name: r.name,\n slug: r.slug,\n description: r.description,\n priceCents: r.priceCents,\n stock: r.stock,\n imageUrl: r.imageUrl,\n category: {\n categoryId: r.categoryId,\n name: r.categoryName,\n slug: r.categorySlug,\n },\n })),\n total: Number(countRow?.count ?? 0),\n }\n },\n})",
|
|
82
|
+
"removeFromBasket": "export const removeFromBasket = pikkuSessionlessFunc({\n expose: true,\n description: 'Remove an item from a basket entirely.',\n input: RemoveFromBasketInput,\n func: async ({ kysely }, { basketId, itemId }) => {\n await kysely\n .deleteFrom('basketItem')\n .where('basketId', '=', basketId)\n .where('itemId', '=', itemId)\n .execute()\n\n await kysely\n .updateTable('basket')\n .set({ updatedAt: new Date().toISOString() })\n .where('basketId', '=', basketId)\n .execute()\n },\n})",
|
|
83
|
+
"rpcClient": "export async function rpcExamples() {\n pikkuRPC.setServerUrl('http://localhost:4000')\n\n const availability = await pikkuRPC.invoke('checkItemAvailability', {\n itemId: 'item-001',\n })\n console.log(availability.available, availability.stock)\n\n pikkuRPC.setAuthorizationJWT('user-jwt-token')\n const basket = await pikkuRPC.invoke('getBasket', {\n sessionId: 'session-123',\n })\n return { availability, basket }\n}",
|
|
84
|
+
"rpcFunc": "// Any pikkuSessionlessFunc is automatically available for internal RPC calls.\n// Add expose: true to also make it reachable via the external RPC endpoint.\nexport const checkItemAvailability = pikkuSessionlessFunc({\n expose: true,\n description:\n 'Check whether an item is in stock and return available quantity.',\n func: async ({ kysely }, { itemId }: { itemId: string }) => {\n const item = await kysely\n .selectFrom('item')\n .select(['stock', 'name', 'isActive'])\n .where('itemId', '=', itemId)\n .executeTakeFirst()\n\n if (!item) throw new Error(`Item not found: ${itemId}`)\n return {\n available: item.stock > 0 && item.isActive === 1,\n stock: item.stock,\n name: item.name,\n }\n },\n})",
|
|
85
|
+
"rpcInternalCall": "// Call another Pikku function by name from inside any function — fully typed,\n// and the same call whether the target is local or in another deployed unit.\n//\n// The example used to be `createOrderWithValidation`, which invoked `getBasket`\n// and returned `{ valid: true }` unconditionally. It was wired to nothing, so\n// the documented way to call a function was demonstrated by a function nobody\n// could call.\nexport const sweepLowStock = pikkuVoidFunc({\n // Exposed so an operator can force a pass and a scenario can prove it works.\n expose: true,\n func: async ({ kysely, logger }, _data, { rpc }) => {\n const rows = await kysely\n .selectFrom('item')\n .select(['itemId', 'name', 'stock'])\n .where('stock', '<=', 5)\n .where('isActive', '=', 1)\n .execute()\n\n for (const row of rows) {\n try {\n await rpc.invoke('onLowStock', {\n itemId: row.itemId,\n name: row.name,\n stock: row.stock,\n })\n } catch (error) {\n // An error pikku knows about carries a status and a message meant for\n // the caller; anything else is a bug and belongs on the floor.\n if (!isExpectedError(error)) throw error\n logger.warn({ event: 'low_stock_alert_failed', itemId: row.itemId })\n }\n }\n\n logger.info({ event: 'low_stock_swept', noticed: rows.length })\n },\n})",
|
|
86
|
+
"scenarioBasics": "// A scenario is a workflow whose steps run as real users (\"actors\") over the\n// real transport — sign-in, auth middleware, permissions and serialization are\n// all exercised end-to-end. The same flow doubles as an e2e test and a\n// staging/production health check. Actors are registered in pikku.config.json.\nexport const shopperBuysAnItem = pikkuScenario({\n title: 'Shopper buys an item',\n description: 'Browse the catalogue, fill a basket and pay for an order.',\n tags: ['checkout'],\n func: async ({ logger }, _input, { scenario, actors }) => {\n if (!actors?.shopper) {\n throw new Error(\n 'shopperBuysAnItem needs the `shopper` actor — run via `pikku scenario run`'\n )\n }\n const shopper = actors.shopper\n\n // Each step names what the actor is trying to achieve, the exposed RPC\n // that achieves it, and who performs it. The call goes through the actor's\n // authenticated client — never internal dispatch.\n const basket = await scenario.do(\n 'Shopper opens their basket',\n 'getBasket',\n {},\n { actor: shopper }\n )\n\n const catalogue = await scenario.do(\n 'Shopper browses the catalogue',\n 'listItems',\n { search: 'mug' },\n { actor: shopper }\n )\n if (catalogue.items.length === 0)\n throw new Error('Catalogue has no mugs to buy')\n\n await scenario.do(\n 'Shopper adds a mug to the basket',\n 'addToBasket',\n {\n basketId: basket.basketId,\n itemId: catalogue.items[0]!.itemId,\n quantity: 1,\n },\n { actor: shopper }\n )\n\n const order = await scenario.do(\n 'Shopper checks out',\n 'createOrder',\n {\n basketId: basket.basketId,\n shippingAddress: {\n line1: '1 High Street',\n city: 'London',\n postcode: 'N1 1AA',\n country: 'GB',\n },\n },\n { actor: shopper }\n )\n\n // Durable polling step: re-invokes the RPC as the actor until the\n // predicate passes, or `within` elapses and fails the scenario.\n await scenario.expectEventually(\n 'Order is paid',\n 'getOrder',\n { orderId: order.orderId },\n (o: { status: string }) => o.status === 'paid',\n { actor: shopper, within: '30s', interval: 500 }\n )\n\n logger.info(\n `Scenario order ${order.orderId} paid: ${order.totalCents} cents`\n )\n return { orderId: order.orderId, totalCents: order.totalCents }\n },\n})",
|
|
87
|
+
"scenarioConfig": "{\n \"scenarios\": {\n \"emailDomain\": \"actors.local\",\n \"environments\": {\n \"local\": {\n \"apiUrl\": \"http://localhost:4011\",\n \"appUrl\": \"http://localhost:7211\",\n \"signInPath\": \"/api/auth/sign-in/actor\"\n },\n \"sandbox\": {\n \"apiUrl\": \"http://localhost:4000\",\n \"signInPath\": \"/api/auth/sign-in/actor\"\n },\n \"coverage\": {\n \"apiUrl\": \"http://localhost:4010\",\n \"signInPath\": \"/api/auth/sign-in/actor\"\n }\n }\n }\n}",
|
|
88
|
+
"scenarioConverse": "// Actors can also hold a free-form conversation with one of your AI agents,\n// in persona. The actor drives the agent over the real transport, answers its\n// tool-approval requests, and returns a verdict on whether the task was met.\nexport const shopperAsksTheAssistant = pikkuScenario({\n before: emptiesTheBasket,\n title: 'Shopper gets help from the assistant',\n description: 'The shop assistant finds a product and adds it to the basket.',\n tags: ['agents'],\n func: async ({ logger }, _input, { scenario, actors }) => {\n if (!actors?.shopper) {\n throw new Error(\n 'shopperAsksTheAssistant needs the `shopper` actor — run via `pikku scenario run`'\n )\n }\n const shopper = actors.shopper\n\n const verdict = await scenario.do(\n 'Shopper chats to the assistant',\n async () =>\n shopper.converse({\n agent: 'shopAssistant',\n task: 'Find a coffee mug in the shop and add one to my basket.',\n evaluate:\n 'The assistant found a mug and confirmed it is in the basket.',\n })\n )\n if (!verdict.passed) {\n logger.error(verdict.transcript.join('\\n'))\n throw new Error(`Assistant flow failed: ${verdict.reasoning}`)\n }\n\n // The verdict is the persona's judgement — follow up with a deterministic\n // check through the same actor.\n const basket = await scenario.do(\n 'Basket really has the mug',\n 'getBasket',\n {},\n { actor: shopper }\n )\n if (basket.itemCount === 0)\n throw new Error('Assistant claimed success but the basket is empty')\n\n return { itemCount: basket.itemCount }\n },\n})",
|
|
89
|
+
"scenarioCookieJar": "/**\n * Signing up is the one flow an actor cannot perform, because an actor is\n * already signed in before the first step runs. The jar is what makes it\n * possible from a bare `fetch`: it keeps the Set-Cookie the sign-up returned\n * and replays it on the next call, so the session survives the hop the way a\n * browser's would.\n */\nexport const signsUpShopper = pikkuScenarioStep<\n { email: string; password: string },\n { signedUpAs?: string }\n>({\n name: 'signsUpShopper',\n description: 'signs a new shopper up and reads back the session it created',\n template: 'signs up as {email}',\n default: async (_services, { email, password }, { scenarioStep }) => {\n const { apiUrl } = requireScenarioEnv(scenarioStep)\n const jar = createCookieJar(apiUrl)\n await jar.fetch(`${apiUrl}/auth/sign-up/email`, {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ email, password, name: email }),\n })\n const session = await readScenarioHttpResponse<{\n user?: { email?: string }\n } | null>(await jar.fetch(`${apiUrl}/auth/get-session`))\n return { signedUpAs: session.body?.user?.email }\n },\n})",
|
|
90
|
+
"scenarioFeature": "export const journeyFeature = pikkuFeature({\n name: 'Browser journey',\n description: 'The job this app exists for, performed by clicking',\n tags: ['journey'],\n scenarios: [shopperBuysAnItemInTheBrowser],\n})",
|
|
91
|
+
"scenarioHook": "/**\n * Empties the shopper's basket before the assistant is asked to fill it.\n *\n * The scenario's real assertion is \"the basket now has the mug\", and a basket\n * a previous run left full satisfies that whether or not the assistant did\n * anything. A hook runs outside the recorded steps, so the tidying never shows\n * up in the report as something the shopper did.\n */\nconst emptiesTheBasket = pikkuScenarioHook(\n async (_services, _data, { actors }) => {\n const basket = await actors.shopper.invoke('getBasket', {})\n for (const item of basket.items) {\n await actors.shopper.invoke('removeFromBasket', {\n basketId: basket.basketId,\n itemId: item.itemId,\n })\n }\n }\n)",
|
|
92
|
+
"scenarioHttpStep": "export const startsCheckout = pikkuScenarioStep<\n { basketId: string; userId: string },\n CheckoutRun\n>({\n name: 'startsCheckout',\n description: 'starts the checkout workflow over HTTP and reports the run',\n template: 'starts checkout for {basketId}',\n default: async (_services, { basketId, userId }, { scenarioStep }) => {\n const { apiUrl } = requireScenarioEnv(scenarioStep)\n const response = await postScenarioJson<{ runId?: string } | undefined>(\n `${apiUrl}/workflow/checkoutWorkflow/start`,\n {\n body: {\n data: {\n basketId,\n userId,\n shippingAddress: {\n line1: '1 High Street',\n city: 'London',\n postcode: 'N1 1AA',\n country: 'GB',\n },\n },\n },\n }\n )\n return { ...response, runId: response.body?.runId }\n },\n})",
|
|
93
|
+
"scenarioPolling": "export const awaitsCheckout = pikkuScenarioStep<\n { run: CheckoutRun; timeoutMs?: number },\n CheckoutRun\n>({\n name: 'awaitsCheckout',\n description: 'waits for a started checkout run to reach a terminal status',\n template: 'waits for the checkout to finish',\n default: async (_services, { run, timeoutMs }, { scenarioStep }) => {\n if (!run.runId) throw new Error(`Checkout never started: ${run.serialized}`)\n const { apiUrl } = requireScenarioEnv(scenarioStep)\n let last = ''\n const finished = await pollUntil(\n async () => {\n const response = await readScenarioHttpResponse<\n { status?: string } | undefined\n >(\n await fetch(`${apiUrl}/workflow/checkoutWorkflow/status/${run.runId}`)\n )\n last = response.serialized\n const outcome = response.body?.status\n return outcome && TERMINAL.includes(outcome)\n ? { ...response, runId: run.runId, outcome }\n : undefined\n },\n { timeoutMs: timeoutMs ?? 30_000, intervalMs: 100 }\n )\n if (!finished) {\n throw new Error(`Run ${run.runId} never finished: ${last}`)\n }\n return finished\n },\n})",
|
|
94
|
+
"scenarioStepDefinition": "export const opensPage = pikkuScenarioStep({\n name: 'opensPage',\n description: 'opens an app page as the signed-in actor',\n template: 'opens {path}',\n input: OpensPageInput,\n output: OpensPageOutput,\n browser: async (_services, { path }, { browser }) => {\n const actor = session(browser)\n const status = await actor.gotoApp(path)\n let pathname = path\n try {\n pathname = new URL(actor.page.url()).pathname\n } catch {\n // A page that never navigated keeps the requested path — the assertion in\n // the scenario is what reports that, not a thrown URL parse error.\n }\n return { pathname, status }\n },\n})",
|
|
95
|
+
"scenarioSteps": "// Each step names what the actor is trying to achieve, the exposed RPC\n// that achieves it, and who performs it. The call goes through the actor's\n// authenticated client — never internal dispatch.\nconst basket = await scenario.do(\n 'Shopper opens their basket',\n 'getBasket',\n {},\n { actor: shopper }\n)\n\nconst catalogue = await scenario.do(\n 'Shopper browses the catalogue',\n 'listItems',\n { search: 'mug' },\n { actor: shopper }\n)\nif (catalogue.items.length === 0)\n throw new Error('Catalogue has no mugs to buy')",
|
|
96
|
+
"scheduledFunction": "export const dailySalesReport = pikkuVoidFunc({\n // Exposed so an operator can run the sweep on demand and a scenario can\n // prove it still works. A job reachable only by its schedule is a job\n // nobody can test and nobody can force when it matters.\n expose: true,\n description: 'Cron job: compute and log a daily sales summary.',\n func: async ({ kysely, logger }) => {\n const yesterday = new Date()\n yesterday.setDate(yesterday.getDate() - 1)\n yesterday.setHours(0, 0, 0, 0)\n const dayStart = yesterday.toISOString()\n\n const today = new Date(yesterday)\n today.setDate(today.getDate() + 1)\n const dayEnd = today.toISOString()\n\n const [summary, topItems] = await Promise.all([\n kysely\n .selectFrom('order')\n .select([\n (eb) => eb.fn.countAll<number>().as('orderCount'),\n (eb) => eb.fn.sum<number>('totalCents').as('revenueCents'),\n ])\n .where('status', '=', 'paid')\n .where('createdAt', '>=', dayStart)\n .where('createdAt', '<', dayEnd)\n .executeTakeFirst(),\n kysely\n .selectFrom('orderItem')\n .innerJoin('order', 'order.orderId', 'orderItem.orderId')\n .innerJoin('item', 'item.itemId', 'orderItem.itemId')\n .select([\n 'item.name',\n (eb) => eb.fn.sum<number>('orderItem.quantity').as('unitsSold'),\n ])\n .where('order.status', '=', 'paid')\n .where('order.createdAt', '>=', dayStart)\n .where('order.createdAt', '<', dayEnd)\n .groupBy('orderItem.itemId')\n .orderBy('unitsSold', 'desc')\n .limit(5)\n .execute(),\n ])\n\n logger.info({\n event: 'daily_sales_report',\n date: yesterday.toISOString().split('T')[0],\n orders: Number(summary?.orderCount ?? 0),\n revenueCents: Number(summary?.revenueCents ?? 0),\n topItems,\n })\n },\n})",
|
|
97
|
+
"scopedFunction": "// Administration is a scope, not a string comparison against a column. The old\n// `session.role === 'admin'` shape could not be checked at build time, could\n// not be granted through the console, and said nothing about *which* admin\n// capability was needed.\n//\n// Defined here rather than beside its `wireHTTP` call: a file that both defines\n// a function and wires it yields \"metadata not found\" from the inspector, and\n// the wiring is skipped at startup. `DELETE /orders/:orderId` was wired,\n// documented, and absent from the running server for exactly that reason.\nexport const deleteOrder = pikkuFunc({\n func: async ({ kysely }, { orderId }: { orderId: string }) => {\n await kysely.deleteFrom('order').where('orderId', '=', orderId).execute()\n },\n scopes: ['orders'],\n})",
|
|
98
|
+
"secrets": "// BETTER_AUTH_SECRET is not declared here — the CLI generates its defineSecret\n// into src/scaffold/auth/ from the pikkuBetterAuth config, along with one per\n// configured provider.\nexport const StripeKeySchema = z.string()\n\ndefineSecret({\n name: 'stripeSecretKey',\n displayName: 'Stripe Secret Key',\n description: 'Stripe secret key (optional — only needed for real payments)',\n secretId: 'STRIPE_SECRET_KEY',\n schema: StripeKeySchema,\n})",
|
|
99
|
+
"sendOrderConfirmation": "export const sendOrderConfirmation = pikkuSessionlessFunc({\n expose: false,\n description:\n 'Queue consumer: send order confirmation email after a successful payment.',\n input: SendOrderConfirmationInput,\n func: async ({ kysely, logger }, { orderId, userId }) => {\n const [order, user] = await Promise.all([\n kysely\n .selectFrom('order')\n .select(['orderId', 'totalCents', 'status', 'createdAt'])\n .where('orderId', '=', orderId)\n .executeTakeFirst(),\n kysely\n .selectFrom('user')\n .select(['email', 'name'])\n .where('id', '=', userId)\n .executeTakeFirst(),\n ])\n\n if (!order || !user) {\n logger.warn(\n `sendOrderConfirmation: order or user not found (${orderId}, ${userId})`\n )\n return\n }\n\n // In production: call your email service here (SendGrid, Resend, etc.)\n logger.info({\n event: 'order_confirmation_sent',\n to: user.email,\n name: user.name,\n orderId: order.orderId,\n totalCents: order.totalCents,\n })\n },\n})",
|
|
100
|
+
"sessionFunction": "export const getSession = pikkuFunc({\n expose: true,\n readonly: true,\n auth: true,\n description: 'Returns the current signed-in user.',\n input: GetSessionInput,\n output: GetSessionOutput,\n func: async ({ kysely }, _input, { session }) => {\n const user = await kysely\n .selectFrom('user')\n .select(['id', 'email', 'name'])\n .where('id', '=', session!.userId)\n .executeTakeFirstOrThrow()\n\n return {\n userId: user.id,\n email: user.email,\n name: user.name ?? null,\n }\n },\n})",
|
|
101
|
+
"shopAuthScope": "// Sessions arrive from Better Auth — the CLI generates the session-bridge\n// middleware from src/wirings/auth.wiring.ts, so nothing here has to read a\n// cookie or a bearer header itself.\n//\n// `auth: true` is the baseline: no session, no call. Authorization lives on\n// the function (see deleteOrder above); the wiring only maps the route to it.\nwireHTTP({\n method: 'delete',\n route: '/orders/:orderId',\n func: deleteOrder,\n auth: true,\n})",
|
|
102
|
+
"shopCredential": "defineCredential({\n name: 'shipping-provider',\n displayName: 'Shipping Provider',\n description: 'OAuth2 connection to the carrier that ships orders',\n type: 'singleton',\n schema: ShippingProviderSchema,\n oauth2: {\n appCredentialSecretId: 'SHIPPING_APP_CREDENTIAL',\n tokenSecretId: 'SHIPPING_TOKENS',\n // These must be literals. The inspector captures the oauth2 block verbatim\n // into the generated meta — it does not evaluate expressions — and that\n // meta is merged into the consuming app's config. A `${...}` template would\n // serialize as broken source text rather than a URL.\n authorizationUrl: 'https://carrier.example.com/oauth/authorize',\n tokenUrl: 'https://carrier.example.com/oauth/token',\n scopes: ['shipments:write'],\n },\n})",
|
|
103
|
+
"shopGetProfile": "/**\n * The signed-in user's stored record, as opposed to the claims in their session.\n *\n * Profile fields live on Better Auth's `user` table — add your own through the\n * plugin's `additionalFields` rather than a second table beside it.\n */\nexport const getProfile = pikkuFunc({\n expose: true,\n description: \"Read the signed-in user's stored profile row.\",\n func: async ({ kysely }, _data, { session }) => {\n return kysely\n .selectFrom('user')\n .select(['id', 'name', 'email', 'role'])\n .where('id', '=', session.userId)\n .executeTakeFirstOrThrow()\n },\n})",
|
|
104
|
+
"shopIsAuthenticated": "export const isAuthenticated = pikkuAuth(\n async (_services, session) => !!session\n)",
|
|
105
|
+
"shopIsOrderOwner": "export const isOrderOwner = pikkuPermission(\n async ({ kysely }, { orderId }: { orderId: string }, { session }) => {\n const order = await kysely\n .selectFrom('order')\n .select('userId')\n .where('orderId', '=', orderId)\n .executeTakeFirst()\n return order?.userId === session?.userId\n }\n)",
|
|
106
|
+
"shopRoutes": "export const shopRoutes = defineHTTPRoutes({\n auth: false,\n routes: {\n // Categories\n listCategories: {\n method: 'get',\n route: '/categories',\n func: listCategories,\n },\n createCategory: {\n method: 'post',\n route: '/categories',\n func: createCategory,\n auth: true,\n },\n\n // Items\n listItems: { method: 'get', route: '/items', func: listItems },\n getItem: { method: 'get', route: '/items/:itemId', func: getItem },\n createItem: {\n method: 'post',\n route: '/items',\n func: createItem,\n auth: true,\n },\n updateItem: {\n method: 'patch',\n route: '/items/:itemId',\n func: updateItem,\n auth: true,\n },\n\n // Account\n getProfile: {\n method: 'get',\n route: '/profile',\n func: getProfile,\n auth: true,\n },\n\n // Basket (sessionless — works for guests too)\n getBasket: { method: 'get', route: '/basket', func: getBasket },\n addToBasket: { method: 'post', route: '/basket/items', func: addToBasket },\n removeFromBasket: {\n method: 'delete',\n route: '/basket/items/:itemId',\n func: removeFromBasket,\n },\n\n // Orders (require auth)\n createOrder: {\n method: 'post',\n route: '/orders',\n func: createOrder,\n auth: true,\n },\n listOrders: {\n method: 'get',\n route: '/orders',\n func: listOrders,\n auth: true,\n },\n getOrder: {\n method: 'get',\n route: '/orders/:orderId',\n func: getOrder,\n auth: true,\n },\n cancelOrder: {\n method: 'post',\n route: '/orders/:orderId/cancel',\n func: cancelOrder,\n auth: true,\n },\n },\n})",
|
|
107
|
+
"shopSecretUsage": "// Note what is *absent* here: `secrets`.\n//\n// Every function-, permission- and auth-facing services type in pikku is\n// bounded by `SecretlessServices`, which omits `secrets` outright. A function\n// cannot read one, so a secret cannot leak through a return value, a log line\n// or an error thrown from business logic — the class of bug you only find in\n// somebody else's incident report.\n//\n// Secrets are read once, where they are needed. `chargeCard` asks the Stripe\n// addon to create a payment intent; `STRIPE_SECRET_KEY` is the addon's secret\n// and never crosses into this function, so it cannot be logged here, returned\n// here, or attached to an error thrown here.\n//\n// This is the step the checkout workflow actually runs. The point used to be\n// made by a second, unwired copy of the same call that existed only to be\n// quoted — so the documented example was the one piece of payment code no\n// shopper ever reached.\nexport const chargeCard = pikkuSessionlessFunc({\n // Invoked by name from `createOrder` as well as by the workflow, so one\n // charge path exists rather than two that can drift.\n expose: true,\n description: 'Charge the card through Stripe.',\n func: async (\n { logger },\n data: { orderId: string; totalCents: number; cardToken?: string },\n { rpc }\n ) => {\n // The order id goes in the payment intent's metadata because that is the\n // only way it comes back: `applyStripeEvent` reads it off the verified\n // webhook event to decide which order was paid. Stripe knows nothing about\n // our ids otherwise.\n const intent = await rpc.invoke('stripe:paymentIntentCreate', {\n amount: data.totalCents,\n currency: 'gbp',\n ...(data.cardToken\n ? { paymentMethod: data.cardToken, confirm: true }\n : {}),\n metadata: { orderId: data.orderId },\n // Charging twice for one order is the expensive failure here, and a\n // workflow step is retried by design.\n idempotencyKey: `order_${data.orderId}`,\n description: `Order ${data.orderId}`,\n })\n\n logger.info({\n event: 'payment_intent_created',\n orderId: data.orderId,\n status: intent.status,\n })\n\n return intent.status === 'succeeded'\n ? { status: 'succeeded' as const, providerRef: intent.id }\n : { status: 'failed' as const, reason: intent.status }\n },\n})",
|
|
108
|
+
"shopServices": "export const createSingletonServices = pikkuServices(\n async (config, existingServices) => {\n const variables =\n existingServices?.variables ??\n new TypedVariablesService(new LocalVariablesService())\n const secrets =\n existingServices?.secrets ??\n new TypedSecretService(new LocalSecretService(variables))\n const logger = existingServices?.logger ?? new JsonConsoleLogger()\n const schema = existingServices?.schema ?? new CFWorkerSchemaService(logger)\n const emailService =\n existingServices?.emailService ??\n new GeneratedTemplateEmailService({\n delegate: new LocalEmailService(),\n })\n // The durable audit sink. In a deployed stage fabric injects the platform's\n // audit service; locally it falls back to a no-op so nothing is persisted.\n const audit = existingServices?.audit ?? new NoopAuditService()\n // kysely is injected by pikku dev (node:sqlite) or the CF Worker workflow (libsql).\n // The template never constructs its own dialect — dialects are fabric/runtime\n // concerns — so it must always be provided by the runtime.\n if (!existingServices?.kysely) {\n throw new Error(\n 'kysely service was not injected by the runtime (pikku dev / fabric)'\n )\n }\n const kysely: Kysely<DB> = existingServices.kysely\n\n // Per-user credential store (wire.getCredential) — needed by addons imported\n // with --auth per-user/delegated. A deployed stage injects it: credentials are\n // sealed to the stage, and only its secrets Worker holds the key. There is no\n // fallback because there is no key to fall back to — the template used to\n // build a `KyselyCredentialService` from a `CREDENTIALS_KEY` secret, which was\n // the stage's own KEK handed to every unit. Outside a stage the app runs fine\n // and credential-using addon calls fail with a clear error from the addon's\n // wire services; wire your own `credentialService` here if you need one.\n const credentialService = existingServices?.credentialService\n\n const litellmProxyUrl = process.env.LITELLM_PROXY_URL ?? null\n const litellmApiKey = process.env.LITELLM_API_KEY ?? null\n let aiAgentRunner: VercelAgentRunner | undefined\n if (litellmProxyUrl && litellmApiKey) {\n // The AI SDKs (~3MB) are stubbed out of non-agent units at bundle time —\n // only units with the `ai-model` capability keep them. So import them\n // dynamically and guard on the module resolving to a real export; in a\n // stubbed unit the import yields `{}` and the runner is simply not built.\n const aiVercel = await import('@pikku/ai-vercel')\n const aiSdk = await import('@ai-sdk/openai-compatible')\n if (aiVercel.VercelAgentRunner && aiSdk.createOpenAICompatible) {\n const litellmProvider = aiSdk.createOpenAICompatible({\n name: 'litellm',\n baseURL: litellmProxyUrl,\n apiKey: litellmApiKey,\n })\n aiAgentRunner = new aiVercel.VercelAgentRunner({\n openai: (modelId: string) => litellmProvider.chatModel(modelId),\n anthropic: (modelId: string) => litellmProvider.chatModel(modelId),\n google: (modelId: string) => litellmProvider.chatModel(modelId),\n deepseek: (modelId: string) => litellmProvider.chatModel(modelId),\n })\n }\n }\n\n // Without a ScopeService no user can hold any scope, so every function that\n // requires one denies everybody — `defineScope`, `defineSystemRole` and the\n // role grants `pikku persona sync` writes all exist and none of them reach a\n // request. It resolves userId -> roles -> scopes out of the four pikku_* tables.\n //\n // Built as the concrete class rather than the `ScopeService` interface because\n // `init()` (which creates those tables if they are absent) is the\n // implementation's, not the contract's. The cast is the app's own `DB` — which\n // includes the pikku_* tables — against the narrower shape the service\n // declares; the two differ only in kysely's internal builder generics.\n let scopeService = existingServices?.scopeService\n if (!scopeService) {\n const kyselyScopes = new KyselyScopeService(\n kysely as unknown as Kysely<KyselyPikkuDB>\n )\n await kyselyScopes.init()\n scopeService = kyselyScopes\n }\n\n return {\n ...(existingServices ?? {}),\n config,\n scopeService,\n workflowService: existingServices?.workflowService,\n workflowRunService: existingServices?.workflowRunService,\n variables,\n secrets,\n schema,\n logger,\n emailService,\n audit,\n kysely,\n // Constructed once here rather than read per call: the payment provider's\n // credentials belong to service construction, and functions cannot reach\n // `secrets` at all (every function-facing services type is bounded by\n // SecretlessServices).\n ...(credentialService ? { credentialService } : {}),\n ...(aiAgentRunner ? { aiAgentRunner } : {}),\n }\n }\n)",
|
|
109
|
+
"shopVersionedItem": "export const GetItemOutputV1 = z.object({\n itemId: z.string(),\n name: z.string(),\n priceCents: z.number(),\n})\n\nexport const getItemV1 = pikkuSessionlessFunc({\n expose: true,\n version: 1,\n input: GetItemInput,\n output: GetItemOutputV1,\n func: async ({ kysely }, { itemId }) => {\n const row = await kysely\n .selectFrom('item')\n .select(['itemId', 'name', 'priceCents'])\n .where('itemId', '=', itemId)\n .executeTakeFirstOrThrow()\n return row\n },\n})\n\n// v2 — adds stock and imageUrl to the response\nexport const getItemV2 = pikkuSessionlessFunc({\n expose: true,\n version: 2,\n input: GetItemInput,\n output: GetItemOutput,\n func: async ({ kysely }, { itemId }) => {\n const row = await kysely\n .selectFrom('item')\n .innerJoin('category', 'category.categoryId', 'item.categoryId')\n .select([\n 'item.itemId',\n 'item.name',\n 'item.slug',\n 'item.description',\n 'item.priceCents',\n 'item.stock',\n 'item.imageUrl',\n 'item.isActive',\n 'item.createdAt',\n 'item.updatedAt',\n 'category.categoryId',\n 'category.name as categoryName',\n 'category.slug as categorySlug',\n ])\n .where('item.itemId', '=', itemId)\n .executeTakeFirstOrThrow()\n return {\n ...row,\n category: {\n categoryId: row.categoryId,\n name: row.categoryName,\n slug: row.categorySlug,\n },\n }\n },\n})",
|
|
110
|
+
"shopWireServices": "export const createWireServices = pikkuWireServices(\n async (singletonServices, wire) => {\n if (!singletonServices.audit) {\n return {}\n }\n const auditLog = createInvocationAudit(singletonServices.audit, wire)\n // auditLog is ALWAYS injected, but `auditLog.write(...)` only PERSISTS when this\n // function set `audit: true` — createInvocationAudit gates on wire.audit, so\n // without it write() is a warn-only no-op (see @pikku/core audit-service.ts).\n // `auditLog.config` is set ONLY when audit: true is on, and when it is, ALSO wrap\n // kysely so every query is captured and the runner flushes the buffer on close.\n // Without audit: true, leave the plain kysely untouched — no per-query overhead.\n if (!auditLog.config) {\n return { auditLog }\n }\n return {\n auditLog,\n kysely: createAuditedKysely(singletonServices.kysely, {\n audit: auditLog,\n }),\n }\n }\n)",
|
|
111
|
+
"tagMiddleware": "addTagMiddleware('checkout', [auditMiddleware('checkout')])",
|
|
112
|
+
"triggerSource": "/**\n * A scheduled task, not a trigger source spinning its own `setInterval`.\n *\n * Noticing that stock has run low is the clock passing rather than an event\n * anybody emits, so something has to look. The original looked by starting a\n * timer inside a trigger source, which reimplemented `wireScheduler` badly: it\n * could not be invoked once, so nothing could test it, an operator had no way\n * to force a pass, and any call would have leaked an interval.\n */\nwireScheduler({\n name: 'sweepLowStock',\n schedule: '*/5 * * * *',\n func: sweepLowStock,\n})",
|
|
113
|
+
"updateItem": "export const updateItem = pikkuFunc({\n expose: true,\n description: 'Update an item. Admin only.',\n // \"Admin only\" was a sentence in a description, not a rule. The route carried\n // `auth: true`, which means signed in — so any shopper with an account could\n // edit the catalogue. `catalogue:write` was already declared in scopes.ts and\n // already granted to the admin role; nothing was checking it.\n scopes: ['catalogue:write'],\n input: UpdateItemInput,\n func: async ({ kysely }, { itemId, ...patch }) => {\n const updates: Record<string, unknown> = {\n updatedAt: new Date().toISOString(),\n }\n if (patch.name !== undefined) updates.name = patch.name\n if (patch.description !== undefined) updates.description = patch.description\n if (patch.priceCents !== undefined) updates.priceCents = patch.priceCents\n if (patch.stock !== undefined) updates.stock = patch.stock\n if (patch.imageUrl !== undefined) updates.imageUrl = patch.imageUrl\n if (patch.isActive !== undefined) updates.isActive = patch.isActive ? 1 : 0\n\n await kysely\n .updateTable('item')\n .set(updates)\n .where('itemId', '=', itemId)\n .execute()\n },\n})",
|
|
114
|
+
"variables": "export const DatabaseUrlSchema = z.string()\nexport const LowStockThresholdSchema = z.number().int().positive()\nexport const ScenarioActorSecretSchema = z.string()\nexport const BetterAuthUrlSchema = z.string().url()\n\ndefineVariable({\n name: 'databaseUrl',\n displayName: 'Database URL',\n description: 'Primary database connection string (Postgres or libsql URL)',\n variableId: 'DATABASE_URL',\n schema: DatabaseUrlSchema,\n})\n\ndefineVariable({\n name: 'lowStockThreshold',\n displayName: 'Low Stock Threshold',\n description: 'Item stock level that triggers a low-stock alert',\n variableId: 'LOW_STOCK_THRESHOLD',\n schema: LowStockThresholdSchema,\n})\n\ndefineVariable({\n name: 'scenarioActorSecret',\n displayName: 'Scenario Actor Secret',\n description:\n 'Impersonation secret for `pikku scenario run` actors. Leave unset to disable actor sign-in',\n variableId: 'SCENARIO_ACTOR_SECRET',\n schema: ScenarioActorSecretSchema,\n})\n\ndefineVariable({\n name: 'betterAuthUrl',\n displayName: 'Better Auth Base URL',\n description:\n 'Public origin the API is served from, used for auth callbacks and redirects',\n variableId: 'BETTER_AUTH_URL',\n schema: BetterAuthUrlSchema,\n})",
|
|
115
|
+
"wireChannel": "export const subscribeToOrder = pikkuChannelFunc<{ orderId: string }, void>(\n async ({ eventHub }, { orderId }, { channel }) => {\n await eventHub?.subscribe(`order:${orderId}`, channel.channelId)\n }\n)\n\nexport const unsubscribeFromOrder = pikkuChannelFunc<{ orderId: string }, void>(\n async ({ eventHub }, { orderId }, { channel }) => {\n await eventHub?.unsubscribe(`order:${orderId}`, channel.channelId)\n }\n)",
|
|
116
|
+
"wireCli": "wireCLI({\n program: 'shop',\n commands: {\n report: pikkuCLICommand({\n description: 'Generate the daily sales report',\n func: dailySalesReport,\n }),\n cleanup: pikkuCLICommand({\n description: 'Remove baskets abandoned for more than 24 h',\n func: cleanupAbandonedBaskets,\n }),\n items: pikkuCLICommand({\n description: 'List all items in the catalogue',\n func: listItems,\n }),\n },\n})",
|
|
117
|
+
"wireHttp": "export const shopRoutes = defineHTTPRoutes({\n auth: false,\n routes: {\n // Categories\n listCategories: {\n method: 'get',\n route: '/categories',\n func: listCategories,\n },\n createCategory: {\n method: 'post',\n route: '/categories',\n func: createCategory,\n auth: true,\n },\n\n // Items\n listItems: { method: 'get', route: '/items', func: listItems },\n getItem: { method: 'get', route: '/items/:itemId', func: getItem },\n createItem: {\n method: 'post',\n route: '/items',\n func: createItem,\n auth: true,\n },\n updateItem: {\n method: 'patch',\n route: '/items/:itemId',\n func: updateItem,\n auth: true,\n },\n\n // Account\n getProfile: {\n method: 'get',\n route: '/profile',\n func: getProfile,\n auth: true,\n },\n\n // Basket (sessionless — works for guests too)\n getBasket: { method: 'get', route: '/basket', func: getBasket },\n addToBasket: { method: 'post', route: '/basket/items', func: addToBasket },\n removeFromBasket: {\n method: 'delete',\n route: '/basket/items/:itemId',\n func: removeFromBasket,\n },\n\n // Orders (require auth)\n createOrder: {\n method: 'post',\n route: '/orders',\n func: createOrder,\n auth: true,\n },\n listOrders: {\n method: 'get',\n route: '/orders',\n func: listOrders,\n auth: true,\n },\n getOrder: {\n method: 'get',\n route: '/orders/:orderId',\n func: getOrder,\n auth: true,\n },\n cancelOrder: {\n method: 'post',\n route: '/orders/:orderId/cancel',\n func: cancelOrder,\n auth: true,\n },\n },\n})",
|
|
118
|
+
"wireQueue": "wireQueueWorker({\n name: 'send-order-confirmation',\n func: sendOrderConfirmation,\n})\n\nwireQueueWorker({\n name: 'audit-event',\n func: writeAuditEvent,\n})",
|
|
119
|
+
"wireScheduler": "wireScheduler({\n name: 'dailySalesReport',\n schedule: '0 6 * * *', // 06:00 UTC every day\n func: dailySalesReport,\n middleware: [timingMiddleware],\n})\n\nwireScheduler({\n name: 'cleanupAbandonedBaskets',\n schedule: '0 3 * * *', // 03:00 UTC every day\n func: cleanupAbandonedBaskets,\n})",
|
|
120
|
+
"wireTrigger": "wireTrigger({\n name: 'low-stock',\n func: onLowStock,\n})",
|
|
121
|
+
"wireTriggerSource": "/**\n * The other half of the same trigger, and the shape a source is actually for:\n * something outside the app pushes, and the app listens.\n *\n * `name` is the contract — it must spell the `wireTrigger` above exactly, or\n * the two never meet. Compare the sweep: a source that starts its own timer is\n * `wireScheduler` written badly, while a source that holds a subscription is\n * the only thing that can do this at all.\n */\nwireTriggerSource({\n name: 'low-stock',\n func: warehouseStockFeed,\n input: { threshold: 5 },\n})",
|
|
122
|
+
"wireWorkflow": "// The workflow body is the plan, not the work: each `workflow.do` names a step\n// and the function that performs it. Pikku records every step in a durable log,\n// so a crash mid-payment resumes at the step that failed rather than charging\n// the card twice.\nexport const checkoutWorkflow = pikkuWorkflowFunc<\n {\n basketId: string\n userId: string\n shippingAddress: ShippingAddress\n cardToken?: string\n },\n { orderId: string; status: 'paid' | 'payment_failed'; totalCents: number }\n>({\n expose: true,\n func: async (_services, data, { workflow }) => {\n const basket = await workflow.do('Validate basket', 'validateBasket', {\n basketId: data.basketId,\n })\n\n const order = await workflow.do('Create order', 'createOrderRecord', {\n userId: data.userId,\n totalCents: basket.totalCents,\n shippingAddress: data.shippingAddress,\n items: basket.items,\n })\n\n // Safely retried if the payment provider times out.\n const payment = await workflow.do('Process payment', 'chargeCard', {\n orderId: order.orderId,\n totalCents: basket.totalCents,\n cardToken: data.cardToken,\n })\n\n const status =\n payment.status === 'succeeded'\n ? ('paid' as const)\n : ('payment_failed' as const)\n\n await workflow.do('Finalize', 'finalizeOrder', {\n orderId: order.orderId,\n basketId: data.basketId,\n userId: data.userId,\n status,\n })\n\n return { orderId: order.orderId, status, totalCents: basket.totalCents }\n },\n})",
|
|
123
|
+
"workflowBranching": "const check = await workflow.do('Check order', 'checkOrderRefundable', {\n orderId: data.orderId,\n})\n\nif (!check.eligible) {\n return {\n orderId: data.orderId,\n refunded: false,\n message: 'Order is not eligible for a refund.',\n }\n}\n\n// Hold for a cooling-off period before issuing the refund\nawait workflow.sleep('Cooling-off delay', '5s')\n\nawait workflow.do('Issue refund', 'issueRefund', { orderId: data.orderId })",
|
|
124
|
+
"workflowComplexFunc": "/**\n * The DSL cannot express \"one step per order item\", so this one is written in\n * TypeScript. `workflow.do` still records each call, so a resumed run replays\n * the loop without repeating the work it already did.\n */\nexport const refundOrderWorkflow = pikkuWorkflowComplexFunc<\n { orderId: string },\n { refunded: number }\n>({\n title: 'Refund Order',\n tags: ['orders'],\n func: async (_services, { orderId }, { workflow }) => {\n const order = await workflow.do('Read the order', 'getOrder', { orderId })\n\n for (const item of order.items) {\n await workflow.do(`Restock ${item.itemId}`, 'onLowStock', {\n itemId: item.itemId,\n name: item.name,\n stock: item.quantity,\n })\n }\n\n await workflow.do('Cancel the order', 'cancelOrder', { orderId })\n\n return { refunded: order.items.length }\n },\n})",
|
|
125
|
+
"workflowGraph": "/**\n * The nightly housekeeping pass, declared as a graph rather than code: each\n * node names an RPC and says what feeds it, so the shape is data the console\n * can draw.\n */\nexport const nightlyHousekeeping = pikkuWorkflowGraph({\n description: 'Sweep abandoned baskets, then report the day',\n tags: ['reports'],\n nodes: {\n sweep: 'cleanupAbandonedBaskets',\n report: 'dailySalesReport',\n },\n config: {\n sweep: {\n next: 'report',\n },\n report: {},\n },\n})",
|
|
126
|
+
"workflowHTTPWiring": "// Expose the checkout workflow via HTTP. rpc.startWorkflow() returns a runId\n// immediately — the workflow runs async in the background.\n// Pikku also auto-generates /workflow/:name/start and /workflow/:name/status/:id routes.\nexport const startCheckout = pikkuFunc({\n // Exposed like every other entry point. Without this the checkout is\n // reachable over HTTP and by nothing else — no scenario, no agent, no\n // internal caller — which is how eight workflow steps went untested.\n expose: true,\n func: async (\n {},\n {\n basketId,\n userId,\n shippingAddress,\n cardToken,\n }: {\n basketId: string\n userId: string\n shippingAddress: ShippingAddress\n cardToken?: string\n },\n { rpc }\n ) => {\n return rpc.startWorkflow('checkoutWorkflow', {\n basketId,\n userId,\n shippingAddress,\n cardToken,\n })\n },\n})\n\nwireHTTP({\n method: 'post',\n route: '/checkout',\n func: startCheckout,\n auth: true,\n})",
|
|
127
|
+
"workflowPatterns": "// A refund workflow demonstrating conditional branching and a built-in sleep step.\n/**\n * The way in to the refund workflow.\n *\n * A workflow cannot be invoked over RPC — `workflow.do` is undefined outside a\n * workflow run — so a complete refund path sat here with `checkOrderRefundable`\n * and `issueRefund` behind it and nothing able to start any of it. Same shape as\n * `startCheckout`: a plain function whose whole job is `rpc.startWorkflow`.\n */\nexport const startRefund = pikkuFunc({\n expose: true,\n description: 'Start the refund workflow for an order.',\n // `orders:refund` is already declared and already granted to support staff —\n // it was simply never demanded by anything.\n scopes: ['orders:refund'],\n func: async (\n {},\n { orderId, reason }: { orderId: string; reason: string },\n { rpc }\n ) => {\n return rpc.startWorkflow('refundWorkflow', { orderId, reason })\n },\n})\n\nexport const refundWorkflow = pikkuWorkflowFunc<\n { orderId: string; reason: string },\n { orderId: string; refunded: boolean; message: string }\n>({\n func: async (_services, data, { workflow }) => {\n const check = await workflow.do('Check order', 'checkOrderRefundable', {\n orderId: data.orderId,\n })\n\n if (!check.eligible) {\n return {\n orderId: data.orderId,\n refunded: false,\n message: 'Order is not eligible for a refund.',\n }\n }\n\n // Hold for a cooling-off period before issuing the refund\n await workflow.sleep('Cooling-off delay', '5s')\n\n await workflow.do('Issue refund', 'issueRefund', { orderId: data.orderId })\n\n return {\n orderId: data.orderId,\n refunded: true,\n message: `Refunded ${(check.totalCents / 100).toFixed(2)} — reason: ${data.reason}`,\n }\n },\n})",
|
|
128
|
+
"workflowSteps": "// Every step of a DSL workflow is an ordinary pikku function. That is what\n// makes a step retryable on its own, replayable from the durable log, and\n// visible as a node in the generated workflow graph.\nexport const validateBasket = pikkuSessionlessFunc({\n description: 'Confirm the basket has items and enough stock to sell.',\n func: async ({ kysely }, { basketId }: { basketId: string }) => {\n const rows = await kysely\n .selectFrom('basketItem')\n .innerJoin('item', 'item.itemId', 'basketItem.itemId')\n .select([\n 'basketItem.itemId',\n 'basketItem.quantity',\n 'item.stock',\n 'item.name',\n 'item.priceCents',\n ])\n .where('basketItem.basketId', '=', basketId)\n .execute()\n\n if (rows.length === 0) throw new Error('Basket is empty')\n for (const i of rows) {\n if (i.quantity > i.stock)\n throw new Error(`Insufficient stock for \"${i.name}\"`)\n }\n\n return {\n items: rows.map((i) => ({\n itemId: i.itemId,\n quantity: i.quantity,\n priceCents: i.priceCents,\n })),\n totalCents: rows.reduce((s, i) => s + i.priceCents * i.quantity, 0),\n }\n },\n})",
|
|
129
|
+
"writeAuditEvent": "export const writeAuditEvent = pikkuSessionlessFunc({\n expose: false,\n description: 'Queue consumer: persist an audit log entry.',\n input: AuditEventInput,\n func: async (\n { kysely },\n { entityType, entityId, action, actorId, payload }\n ) => {\n await kysely\n .insertInto('auditLog')\n .values({\n auditId: randomUUID(),\n entityType,\n entityId,\n action,\n actorId: actorId ?? null,\n payload: payload ? JSON.stringify(payload) : null,\n createdAt: new Date().toISOString(),\n })\n .execute()\n },\n})",
|
|
130
|
+
"writeFunction": "export const addToBasket = pikkuSessionlessFunc({\n expose: true,\n description:\n 'Add an item to a basket, or increase its quantity if already present.',\n input: AddToBasketInput,\n func: async ({ kysely }, { basketId, itemId, quantity }) => {\n const item = await kysely\n .selectFrom('item')\n .select(['stock', 'isActive'])\n .where('itemId', '=', itemId)\n .executeTakeFirst()\n\n if (!item || !item.isActive) throw new Error('Item not available')\n\n const existing = await kysely\n .selectFrom('basketItem')\n .select(['basketItemId', 'quantity'])\n .where('basketId', '=', basketId)\n .where('itemId', '=', itemId)\n .executeTakeFirst()\n\n if (existing) {\n const newQty = existing.quantity + quantity\n if (newQty > item.stock) throw new Error('Not enough stock')\n await kysely\n .updateTable('basketItem')\n .set({ quantity: newQty })\n .where('basketItemId', '=', existing.basketItemId)\n .execute()\n } else {\n if (quantity > item.stock) throw new Error('Not enough stock')\n await kysely\n .insertInto('basketItem')\n .values({ basketItemId: randomUUID(), basketId, itemId, quantity })\n .execute()\n }\n\n await kysely\n .updateTable('basket')\n .set({ updatedAt: new Date().toISOString() })\n .where('basketId', '=', basketId)\n .execute()\n },\n})"
|
|
131
|
+
}
|