ts-procedures 5.2.0 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,141 @@
1
+ # ts-procedures Review Checklist
2
+
3
+ ## Procedure Definition Checks
4
+
5
+ ### CRITICAL
6
+ - [ ] Uses `ctx.error()` for business logic errors, never throws raw `Error` instances
7
+ - [ ] Does not expect `schema.returnType` to be validated at runtime (it's documentation only)
8
+ - [ ] No duplicate procedure names within the same factory
9
+ - [ ] Schema uses TypeBox builders (`Type.Object(...)`, etc.), not plain JSON Schema objects
10
+ - [ ] Stream handlers check `ctx.signal.aborted` in loops to prevent infinite resource consumption
11
+
12
+ ### WARNING
13
+ - [ ] Passes `ctx.signal` to all downstream async calls (fetch, database queries, etc.)
14
+ - [ ] Does not put manual validation logic in handler when `schema.params` handles it
15
+ - [ ] Does not swallow errors in try/catch without re-throwing (hides failures)
16
+ - [ ] Uses `validateYields: true` only when `schema.yieldType` is also defined
17
+ - [ ] Does not mix Create procedures and CreateStream procedures in confusing ways
18
+
19
+ ### SUGGESTION
20
+ - [ ] Has `description` field set for documentation/introspection
21
+ - [ ] Uses meaningful procedure names (PascalCase, verb-noun: `GetUser`, `CreateOrder`)
22
+ - [ ] Destructures return value for clean exports: `const { GetUser, info } = Create(...)`
23
+
24
+ ---
25
+
26
+ ## Schema Checks
27
+
28
+ ### CRITICAL
29
+ - [ ] Uses TypeBox (`Type.Object(...)`, `Type.String()`, etc.) — not plain objects
30
+ - [ ] Required fields use `Type.String()` directly; optional fields wrapped with `Type.Optional(...)`
31
+
32
+ ### WARNING
33
+ - [ ] Understands AJV `coerceTypes: true` — no manual type parsing for query string values
34
+ - [ ] Understands AJV `removeAdditional: true` — extra fields in params are silently stripped
35
+ - [ ] Does not rely on params fields not in the schema (they get removed)
36
+
37
+ ### SUGGESTION
38
+ - [ ] `returnType` schema provided for documentation generation
39
+ - [ ] `yieldType` schema provided for streaming procedures
40
+
41
+ ---
42
+
43
+ ## Context Checks
44
+
45
+ ### CRITICAL
46
+ - [ ] Factory has explicit `TContext` type parameter when using HTTP builders
47
+ - [ ] Context factory function handles errors gracefully (async failures don't crash requests)
48
+
49
+ ### WARNING
50
+ - [ ] Context type includes `signal?: AbortSignal` if procedures need cancellation support
51
+ - [ ] Context factory resolves only what handlers need (avoid expensive unused computations)
52
+
53
+ ### SUGGESTION
54
+ - [ ] Context type is documented or self-descriptive
55
+ - [ ] Separate factories for different access levels (public vs authenticated)
56
+
57
+ ---
58
+
59
+ ## HTTP Builder Checks (Express/Hono)
60
+
61
+ ### CRITICAL
62
+ - [ ] Standard `Create` procedures registered with `ExpressRPCAppBuilder` or `HonoRPCAppBuilder`, NOT `HonoStreamAppBuilder`
63
+ - [ ] Stream `CreateStream` procedures registered with `HonoStreamAppBuilder`, NOT the RPC builders
64
+ - [ ] `onError` callback handles `ProcedureValidationError` and `ProcedureError` differently
65
+
66
+ ### WARNING
67
+ - [ ] `pathPrefix` set consistently across builders
68
+ - [ ] `scope` and `version` set on all procedures when using `RPCConfig`
69
+ - [ ] Uses `extendProcedureDoc` for documentation generation instead of manual doc building
70
+ - [ ] `onRequestEnd` used for cleanup/logging, not business logic
71
+
72
+ ### SUGGESTION
73
+ - [ ] Route documentation accessed via `builder.docs` for OpenAPI generation
74
+ - [ ] Lifecycle hooks used for observability (logging, metrics)
75
+
76
+ ---
77
+
78
+ ## Streaming Checks (HonoStreamAppBuilder)
79
+
80
+ ### CRITICAL
81
+ - [ ] `onPreStreamError` handles validation errors before streaming starts (returns HTTP response)
82
+ - [ ] `onMidStreamError` handles runtime errors during streaming (yields error event)
83
+ - [ ] Stream handler does not assume `signal.reason` is always `'stream-completed'` — check for external abort
84
+
85
+ ### WARNING
86
+ - [ ] Uses `sse()` helper for SSE metadata when custom event types or IDs are needed
87
+ - [ ] Correctly distinguishes stream modes: `'sse'` for EventSource clients, `'text'` for simple consumers
88
+ - [ ] Does not yield after `ctx.signal.aborted` is true
89
+
90
+ ### SUGGESTION
91
+ - [ ] Stream mode documented in comments or config
92
+ - [ ] Error data shape matches `yieldType` schema for consistent client parsing
93
+
94
+ ---
95
+
96
+ ## Error Handling Checks
97
+
98
+ ### CRITICAL
99
+ - [ ] Handler errors use `ctx.error(message, meta?)` — not `throw new Error()`
100
+ - [ ] Does not catch and swallow errors without re-throwing (hides failures from caller)
101
+ - [ ] HTTP builder has `onError` callback — default behavior may expose internal details
102
+
103
+ ### WARNING
104
+ - [ ] `ProcedureValidationError` handled separately (400 status) from `ProcedureError` (4xx/5xx)
105
+ - [ ] Error `meta` object contains useful context (error codes, IDs) — not sensitive data
106
+ - [ ] Stack traces not exposed in production responses
107
+
108
+ ### SUGGESTION
109
+ - [ ] Consistent error response shape across all procedures
110
+ - [ ] Error codes documented for API consumers
111
+
112
+ ---
113
+
114
+ ## Test File Checks
115
+
116
+ ### CRITICAL
117
+ - [ ] Tests both valid and invalid param scenarios
118
+ - [ ] Tests `ProcedureValidationError` is thrown for schema violations
119
+ - [ ] Tests `ProcedureError` is thrown for business logic failures
120
+
121
+ ### WARNING
122
+ - [ ] Tests pass context with required fields (doesn't rely on undefined context)
123
+ - [ ] HTTP builder tests verify response status codes and body shape
124
+ - [ ] Stream tests consume values with `for await...of` and break after expected count
125
+
126
+ ### SUGGESTION
127
+ - [ ] Tests verify `info` metadata (name, schema, description)
128
+ - [ ] Tests verify `getProcedures()` returns expected registrations
129
+ - [ ] Tests verify error `meta` contains expected fields
130
+
131
+ ---
132
+
133
+ ## Extended Config Checks
134
+
135
+ ### WARNING
136
+ - [ ] All procedures include required `TExtendedConfig` fields (e.g., `scope`, `version` for `RPCConfig`)
137
+ - [ ] Custom config fields used consistently across procedure group
138
+
139
+ ### SUGGESTION
140
+ - [ ] Custom config fields documented
141
+ - [ ] `getProcedures()` used for config introspection (permissions audit, etc.)
@@ -0,0 +1,54 @@
1
+ ---
2
+ name: scaffold
3
+ description: "Scaffold ts-procedures code with correct patterns. Usage: /ts-procedures:scaffold <type> <Name>"
4
+ invocable_by:
5
+ - user
6
+ - model
7
+ user_instructions: |
8
+ Usage: /ts-procedures:scaffold <type> <Name>
9
+
10
+ Types: procedure, stream-procedure, express-rpc, hono-rpc, hono-stream
11
+
12
+ Examples:
13
+ /ts-procedures:scaffold procedure GetUser
14
+ /ts-procedures:scaffold stream-procedure StreamActivity
15
+ /ts-procedures:scaffold express-rpc UserApi
16
+ /ts-procedures:scaffold hono-rpc OrderApi
17
+ /ts-procedures:scaffold hono-stream LiveFeed
18
+ ---
19
+
20
+ # Scaffold ts-procedures Code
21
+
22
+ Parse `$ARGUMENTS` as `<type> <Name>` (case-insensitive type, PascalCase Name).
23
+
24
+ ## Instructions
25
+
26
+ 1. Parse the arguments. If missing, ask the user for `<type>` and `<Name>`.
27
+ 2. Derive placeholder variants from the provided PascalCase Name:
28
+ - `{{Name}}` — PascalCase as given (e.g., `UserProfile`)
29
+ - `{{name}}` — camelCase (e.g., `userProfile`)
30
+ - For URL scopes and file paths, use kebab-case (e.g., `user-profile`)
31
+ 3. Read the template file from `templates/<type>.md` in this skill directory.
32
+ 4. Replace all `{{Name}}` and `{{name}}` placeholders with the appropriate variants.
33
+ 5. Generate the implementation file(s) following the template exactly.
34
+ 6. Also generate a colocated test file following ts-procedures test conventions.
35
+
36
+ ## Valid Types
37
+
38
+ | Type | Template | Files Generated |
39
+ |------|----------|----------------|
40
+ | `procedure` | `templates/procedure.md` | `{{Name}}.procedure.ts`, `{{Name}}.procedure.test.ts` |
41
+ | `stream-procedure` | `templates/stream-procedure.md` | `{{Name}}.stream.ts`, `{{Name}}.stream.test.ts` |
42
+ | `express-rpc` | `templates/express-rpc.md` | `{{Name}}.rpc.ts`, `{{Name}}.rpc.test.ts` |
43
+ | `hono-rpc` | `templates/hono-rpc.md` | `{{Name}}.rpc.ts`, `{{Name}}.rpc.test.ts` |
44
+ | `hono-stream` | `templates/hono-stream.md` | `{{Name}}.stream-rpc.ts`, `{{Name}}.stream-rpc.test.ts` |
45
+
46
+ ## Rules
47
+
48
+ - Use `ctx.error()` for business logic errors, never throw raw `Error`.
49
+ - `schema.params` is validated at runtime; `schema.returnType` is documentation only.
50
+ - Pass `ctx.signal` to all downstream async calls.
51
+ - Stream handlers always have `ctx.signal` (guaranteed `AbortSignal`).
52
+ - Use TypeBox for schema definitions (`import { Type } from 'typebox'`).
53
+ - AJV config: `allErrors: true`, `coerceTypes: true`, `removeAdditional: true`.
54
+ - Test files use `describe`/`test` from vitest.
@@ -0,0 +1,134 @@
1
+ # Express RPC Template: {{Name}}
2
+
3
+ ## Implementation — `{{Name}}.rpc.ts`
4
+
5
+ ```typescript
6
+ import { Procedures, ProcedureError, ProcedureValidationError } from 'ts-procedures'
7
+ import { ExpressRPCAppBuilder } from 'ts-procedures/express-rpc'
8
+ import type { RPCConfig } from 'ts-procedures/http'
9
+ import { Type } from 'typebox'
10
+
11
+ // ─── Context ──────────────────────────────────────────────
12
+
13
+ type {{Name}}Context = {
14
+ userId: string
15
+ requestId: string
16
+ }
17
+
18
+ // ─── Procedures ───────────────────────────────────────────
19
+
20
+ const RPC = Procedures<{{Name}}Context, RPCConfig>()
21
+
22
+ export const { GetItem } = RPC.Create(
23
+ 'GetItem',
24
+ {
25
+ scope: '{{name}}', // TODO: set URL scope
26
+ version: 1,
27
+ description: 'Fetch item by ID',
28
+ schema: {
29
+ params: Type.Object({
30
+ id: Type.String(),
31
+ }),
32
+ returnType: Type.Object({
33
+ id: Type.String(),
34
+ name: Type.String(),
35
+ }),
36
+ },
37
+ },
38
+ async (ctx, params) => {
39
+ // TODO: implement
40
+ return { id: params.id, name: 'Example' }
41
+ }
42
+ )
43
+
44
+ export const { ListItems } = RPC.Create(
45
+ 'ListItems',
46
+ {
47
+ scope: '{{name}}',
48
+ version: 1,
49
+ description: 'List all items',
50
+ schema: {
51
+ params: Type.Object({
52
+ page: Type.Optional(Type.Number()),
53
+ limit: Type.Optional(Type.Number()),
54
+ }),
55
+ },
56
+ },
57
+ async (ctx, params) => {
58
+ // TODO: implement
59
+ return { items: [], total: 0 }
60
+ }
61
+ )
62
+
63
+ // ─── Express App Builder ──────────────────────────────────
64
+
65
+ export const {{name}}App = new ExpressRPCAppBuilder({
66
+ pathPrefix: '/api',
67
+ onError: (procedure, req, res, error) => {
68
+ if (error instanceof ProcedureValidationError) {
69
+ res.status(400).json({
70
+ error: error.message,
71
+ details: error.errors,
72
+ procedure: error.procedureName,
73
+ })
74
+ } else if (error instanceof ProcedureError) {
75
+ res.status(422).json({
76
+ error: error.message,
77
+ meta: error.meta,
78
+ procedure: error.procedureName,
79
+ })
80
+ } else {
81
+ res.status(500).json({ error: 'Internal server error' })
82
+ }
83
+ },
84
+ })
85
+ .register(RPC, async (req) => ({
86
+ userId: req.headers['x-user-id'] as string || 'anonymous',
87
+ requestId: req.headers['x-request-id'] as string || crypto.randomUUID(),
88
+ }))
89
+ .build()
90
+
91
+ // Route map:
92
+ // POST /api/{{name}}/get-item/1
93
+ // POST /api/{{name}}/list-items/1
94
+
95
+ // Documentation: {{name}}App.docs
96
+ ```
97
+
98
+ ## Test — `{{Name}}.rpc.test.ts`
99
+
100
+ ```typescript
101
+ import { describe, test, expect } from 'vitest'
102
+ import supertest from 'supertest'
103
+ import { {{name}}App } from './{{Name}}.rpc'
104
+
105
+ describe('{{Name}} RPC', () => {
106
+ test('GET item returns expected result', async () => {
107
+ const res = await supertest({{name}}App)
108
+ .post('/api/{{name}}/get-item/1')
109
+ .send({ id: 'item-1' })
110
+ .expect(200)
111
+
112
+ expect(res.body).toEqual({ id: 'item-1', name: 'Example' })
113
+ })
114
+
115
+ test('returns 400 for invalid params', async () => {
116
+ const res = await supertest({{name}}App)
117
+ .post('/api/{{name}}/get-item/1')
118
+ .send({})
119
+ .expect(400)
120
+
121
+ expect(res.body.error).toBeDefined()
122
+ })
123
+
124
+ test('LIST items returns array', async () => {
125
+ const res = await supertest({{name}}App)
126
+ .post('/api/{{name}}/list-items/1')
127
+ .send({ page: 1, limit: 10 })
128
+ .expect(200)
129
+
130
+ expect(res.body).toHaveProperty('items')
131
+ expect(res.body).toHaveProperty('total')
132
+ })
133
+ })
134
+ ```
@@ -0,0 +1,139 @@
1
+ # Hono RPC Template: {{Name}}
2
+
3
+ ## Implementation — `{{Name}}.rpc.ts`
4
+
5
+ ```typescript
6
+ import { Procedures, ProcedureError, ProcedureValidationError } from 'ts-procedures'
7
+ import { HonoRPCAppBuilder } from 'ts-procedures/hono-rpc'
8
+ import type { RPCConfig } from 'ts-procedures/http'
9
+ import { Type } from 'typebox'
10
+
11
+ // ─── Context ──────────────────────────────────────────────
12
+
13
+ type {{Name}}Context = {
14
+ userId: string
15
+ requestId: string
16
+ }
17
+
18
+ // ─── Procedures ───────────────────────────────────────────
19
+
20
+ const RPC = Procedures<{{Name}}Context, RPCConfig>()
21
+
22
+ export const { GetItem } = RPC.Create(
23
+ 'GetItem',
24
+ {
25
+ scope: '{{name}}', // TODO: set URL scope
26
+ version: 1,
27
+ description: 'Fetch item by ID',
28
+ schema: {
29
+ params: Type.Object({
30
+ id: Type.String(),
31
+ }),
32
+ returnType: Type.Object({
33
+ id: Type.String(),
34
+ name: Type.String(),
35
+ }),
36
+ },
37
+ },
38
+ async (ctx, params) => {
39
+ // TODO: implement
40
+ return { id: params.id, name: 'Example' }
41
+ }
42
+ )
43
+
44
+ export const { ListItems } = RPC.Create(
45
+ 'ListItems',
46
+ {
47
+ scope: '{{name}}',
48
+ version: 1,
49
+ description: 'List all items',
50
+ schema: {
51
+ params: Type.Object({
52
+ page: Type.Optional(Type.Number()),
53
+ limit: Type.Optional(Type.Number()),
54
+ }),
55
+ },
56
+ },
57
+ async (ctx, params) => {
58
+ // TODO: implement
59
+ return { items: [], total: 0 }
60
+ }
61
+ )
62
+
63
+ // ─── Hono App Builder ─────────────────────────────────────
64
+
65
+ export const {{name}}App = new HonoRPCAppBuilder({
66
+ pathPrefix: '/api',
67
+ onError: (procedure, c, error) => {
68
+ if (error instanceof ProcedureValidationError) {
69
+ return c.json({
70
+ error: error.message,
71
+ details: error.errors,
72
+ procedure: error.procedureName,
73
+ }, 400)
74
+ } else if (error instanceof ProcedureError) {
75
+ return c.json({
76
+ error: error.message,
77
+ meta: error.meta,
78
+ procedure: error.procedureName,
79
+ }, 422)
80
+ }
81
+ return c.json({ error: 'Internal server error' }, 500)
82
+ },
83
+ })
84
+ .register(RPC, (c) => ({
85
+ userId: c.req.header('x-user-id') || 'anonymous',
86
+ requestId: c.req.header('x-request-id') || crypto.randomUUID(),
87
+ }))
88
+ .build()
89
+
90
+ // Route map:
91
+ // POST /api/{{name}}/get-item/1
92
+ // POST /api/{{name}}/list-items/1
93
+
94
+ // Documentation: {{name}}App.docs
95
+ ```
96
+
97
+ ## Test — `{{Name}}.rpc.test.ts`
98
+
99
+ ```typescript
100
+ import { describe, test, expect } from 'vitest'
101
+ import { {{name}}App } from './{{Name}}.rpc'
102
+
103
+ describe('{{Name}} RPC', () => {
104
+ test('GET item returns expected result', async () => {
105
+ const res = await {{name}}App.request('/api/{{name}}/get-item/1', {
106
+ method: 'POST',
107
+ headers: { 'Content-Type': 'application/json' },
108
+ body: JSON.stringify({ id: 'item-1' }),
109
+ })
110
+
111
+ expect(res.status).toBe(200)
112
+ const body = await res.json()
113
+ expect(body).toEqual({ id: 'item-1', name: 'Example' })
114
+ })
115
+
116
+ test('returns 400 for invalid params', async () => {
117
+ const res = await {{name}}App.request('/api/{{name}}/get-item/1', {
118
+ method: 'POST',
119
+ headers: { 'Content-Type': 'application/json' },
120
+ body: JSON.stringify({}),
121
+ })
122
+
123
+ expect(res.status).toBe(400)
124
+ })
125
+
126
+ test('LIST items returns array', async () => {
127
+ const res = await {{name}}App.request('/api/{{name}}/list-items/1', {
128
+ method: 'POST',
129
+ headers: { 'Content-Type': 'application/json' },
130
+ body: JSON.stringify({ page: 1, limit: 10 }),
131
+ })
132
+
133
+ expect(res.status).toBe(200)
134
+ const body = await res.json()
135
+ expect(body).toHaveProperty('items')
136
+ expect(body).toHaveProperty('total')
137
+ })
138
+ })
139
+ ```
@@ -0,0 +1,134 @@
1
+ # Hono Stream Template: {{Name}}
2
+
3
+ ## Implementation — `{{Name}}.stream-rpc.ts`
4
+
5
+ ```typescript
6
+ import { Procedures } from 'ts-procedures'
7
+ import { HonoStreamAppBuilder, sse } from 'ts-procedures/hono-stream'
8
+ import type { RPCConfig } from 'ts-procedures/http'
9
+ import { Type } from 'typebox'
10
+
11
+ // ─── Context ──────────────────────────────────────────────
12
+
13
+ type {{Name}}Context = {
14
+ userId: string
15
+ }
16
+
17
+ // ─── Stream Procedures ────────────────────────────────────
18
+
19
+ const StreamRPC = Procedures<{{Name}}Context, RPCConfig>()
20
+
21
+ export const { {{Name}}Stream } = StreamRPC.CreateStream(
22
+ '{{Name}}Stream',
23
+ {
24
+ scope: '{{name}}', // TODO: set URL scope
25
+ version: 1,
26
+ description: 'TODO: describe what this streams',
27
+ schema: {
28
+ params: Type.Object({
29
+ // TODO: define input parameters
30
+ channel: Type.String(),
31
+ }),
32
+ yieldType: Type.Object({
33
+ // TODO: define shape of each yielded value
34
+ type: Type.String(),
35
+ payload: Type.Any(),
36
+ timestamp: Type.Number(),
37
+ }),
38
+ },
39
+ },
40
+ async function* (ctx, params) {
41
+ // ctx.signal is ALWAYS present in stream handlers
42
+
43
+ // TODO: implement streaming logic
44
+ let counter = 0
45
+ while (!ctx.signal.aborted) {
46
+ const event = await pollForEvents(params.channel, { signal: ctx.signal })
47
+
48
+ // Use sse() to attach SSE metadata (event type, id, retry)
49
+ yield sse(
50
+ { type: event.type, payload: event.data, timestamp: Date.now() },
51
+ { event: event.type, id: String(counter++) }
52
+ )
53
+ }
54
+ }
55
+ )
56
+
57
+ // Placeholder — replace with real event source
58
+ async function pollForEvents(channel: string, opts?: { signal?: AbortSignal }) {
59
+ await new Promise(r => setTimeout(r, 1000))
60
+ return { type: 'update', data: { value: Math.random() } }
61
+ }
62
+
63
+ // ─── Hono Stream App Builder ──────────────────────────────
64
+
65
+ export const {{name}}StreamApp = new HonoStreamAppBuilder({
66
+ pathPrefix: '/api',
67
+ defaultStreamMode: 'sse', // or 'text' for newline-delimited JSON
68
+ onPreStreamError: (procedure, c, error) => {
69
+ return c.json({ error: error.message }, 400)
70
+ },
71
+ onMidStreamError: (procedure, c, error) => ({
72
+ data: { type: 'error', payload: { message: error.message }, timestamp: Date.now() },
73
+ closeStream: true,
74
+ }),
75
+ })
76
+ .register(StreamRPC, (c) => ({
77
+ userId: c.req.header('x-user-id') || 'anonymous',
78
+ }))
79
+ .build()
80
+
81
+ // Routes:
82
+ // GET /api/{{name}}/{{name}}-stream/1?channel=test
83
+ // POST /api/{{name}}/{{name}}-stream/1 (body: { channel: "test" })
84
+
85
+ // SSE output per yield:
86
+ // event: {{Name}}Stream
87
+ // id: 0
88
+ // data: {"type":"update","payload":{...},"timestamp":1234567890}
89
+ ```
90
+
91
+ ## Test — `{{Name}}.stream-rpc.test.ts`
92
+
93
+ ```typescript
94
+ import { describe, test, expect } from 'vitest'
95
+ import { {{name}}StreamApp } from './{{Name}}.stream-rpc'
96
+
97
+ describe('{{Name}} Stream RPC', () => {
98
+ test('GET returns SSE stream', async () => {
99
+ const res = await {{name}}StreamApp.request(
100
+ '/api/{{name}}/{{name}}-stream/1?channel=test'
101
+ )
102
+
103
+ expect(res.status).toBe(200)
104
+ expect(res.headers.get('content-type')).toContain('text/event-stream')
105
+ })
106
+
107
+ test('POST returns SSE stream', async () => {
108
+ const res = await {{name}}StreamApp.request(
109
+ '/api/{{name}}/{{name}}-stream/1',
110
+ {
111
+ method: 'POST',
112
+ headers: { 'Content-Type': 'application/json' },
113
+ body: JSON.stringify({ channel: 'test' }),
114
+ }
115
+ )
116
+
117
+ expect(res.status).toBe(200)
118
+ expect(res.headers.get('content-type')).toContain('text/event-stream')
119
+ })
120
+
121
+ test('returns 400 for invalid params', async () => {
122
+ const res = await {{name}}StreamApp.request(
123
+ '/api/{{name}}/{{name}}-stream/1',
124
+ {
125
+ method: 'POST',
126
+ headers: { 'Content-Type': 'application/json' },
127
+ body: JSON.stringify({}),
128
+ }
129
+ )
130
+
131
+ expect(res.status).toBe(400)
132
+ })
133
+ })
134
+ ```
@@ -0,0 +1,77 @@
1
+ # Procedure Template: {{Name}}
2
+
3
+ ## Implementation — `{{Name}}.procedure.ts`
4
+
5
+ ```typescript
6
+ import { Procedures } from 'ts-procedures'
7
+ import { Type } from 'typebox'
8
+
9
+ // Define context type (adjust to match your app's context)
10
+ type AppContext = {
11
+ userId: string
12
+ signal?: AbortSignal
13
+ }
14
+
15
+ const { Create } = Procedures<AppContext>()
16
+
17
+ export const { {{Name}}, procedure: {{Name}}Procedure, info: {{Name}}Info } = Create(
18
+ '{{Name}}',
19
+ {
20
+ description: 'TODO: describe what {{Name}} does',
21
+ schema: {
22
+ params: Type.Object({
23
+ // TODO: define input parameters
24
+ id: Type.String(),
25
+ }),
26
+ returnType: Type.Object({
27
+ // TODO: define return shape (documentation only, not validated at runtime)
28
+ id: Type.String(),
29
+ // ...fields
30
+ }),
31
+ },
32
+ },
33
+ async (ctx, params) => {
34
+ // params.id is guaranteed to be a string (validated by AJV)
35
+
36
+ // Use ctx.error() for business logic errors
37
+ // throw ctx.error('Not found', { code: 'NOT_FOUND', id: params.id })
38
+
39
+ // Pass ctx.signal to downstream async calls
40
+ // const data = await fetch(url, { signal: ctx.signal })
41
+
42
+ // TODO: implement handler logic
43
+ return { id: params.id }
44
+ }
45
+ )
46
+ ```
47
+
48
+ ## Test — `{{Name}}.procedure.test.ts`
49
+
50
+ ```typescript
51
+ import { describe, test, expect } from 'vitest'
52
+ import { ProcedureError, ProcedureValidationError } from 'ts-procedures'
53
+ import { {{Name}} } from './{{Name}}.procedure'
54
+
55
+ const mockContext = { userId: 'test-user' }
56
+
57
+ describe('{{Name}}', () => {
58
+ test('returns expected result for valid params', async () => {
59
+ const result = await {{Name}}(mockContext, { id: 'test-id' })
60
+ expect(result).toBeDefined()
61
+ expect(result.id).toBe('test-id')
62
+ })
63
+
64
+ test('throws ProcedureValidationError for invalid params', async () => {
65
+ await expect(
66
+ {{Name}}(mockContext, {} as any)
67
+ ).rejects.toThrow(ProcedureValidationError)
68
+ })
69
+
70
+ test('throws ProcedureError for business logic failures', async () => {
71
+ // TODO: test ctx.error() scenarios
72
+ // await expect(
73
+ // {{Name}}(mockContext, { id: 'invalid' })
74
+ // ).rejects.toThrow(ProcedureError)
75
+ })
76
+ })
77
+ ```