sonamu 0.10.7 → 0.10.8
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/dist/bin/cli.js +10 -253
- package/dist/migration/code-generation.js +2 -2
- package/dist/ui-web/assets/{index-D0MHYbxl.js → index-CJf8uJYf.js} +47 -41
- package/dist/ui-web/assets/index-GMMIVGja.css +1 -0
- package/dist/ui-web/index.html +2 -2
- package/package.json +3 -3
- package/src/bin/cli.ts +12 -375
- package/src/migration/code-generation.ts +1 -1
- package/dist/ui-web/assets/index-Dx_JX4aQ.css +0 -1
- package/src/skills/AGENTS.md +0 -108
- package/src/skills/sonamu/SKILL.md +0 -75
- package/src/skills/sonamu-ai-agents/SKILL.md +0 -205
- package/src/skills/sonamu-api/SKILL.md +0 -480
- package/src/skills/sonamu-auth/SKILL.md +0 -327
- package/src/skills/sonamu-auth/references/plugins.md +0 -310
- package/src/skills/sonamu-auth/references/user-id-migration-followups.md +0 -192
- package/src/skills/sonamu-auth/references/user-id-migration.md +0 -455
- package/src/skills/sonamu-config/SKILL.md +0 -203
- package/src/skills/sonamu-config/references/database.md +0 -452
- package/src/skills/sonamu-config/references/environments.md +0 -178
- package/src/skills/sonamu-config/references/server-options.md +0 -400
- package/src/skills/sonamu-entity/SKILL.md +0 -180
- package/src/skills/sonamu-entity/references/creation-workflow.md +0 -581
- package/src/skills/sonamu-entity/references/design-guides.md +0 -243
- package/src/skills/sonamu-entity/references/field-types.md +0 -170
- package/src/skills/sonamu-entity/references/relations-detail.md +0 -245
- package/src/skills/sonamu-entity/references/relations.md +0 -463
- package/src/skills/sonamu-entity/references/subset.md +0 -156
- package/src/skills/sonamu-fixture/SKILL.md +0 -180
- package/src/skills/sonamu-fixture/references/cli-usage.md +0 -439
- package/src/skills/sonamu-fixture/references/cone.md +0 -298
- package/src/skills/sonamu-frontend/SKILL.md +0 -142
- package/src/skills/sonamu-frontend/references/components.md +0 -323
- package/src/skills/sonamu-frontend/references/examples.md +0 -64
- package/src/skills/sonamu-frontend/references/hooks.md +0 -273
- package/src/skills/sonamu-frontend/references/runtime.md +0 -165
- package/src/skills/sonamu-frontend/references/scaffolding.md +0 -439
- package/src/skills/sonamu-i18n/SKILL.md +0 -287
- package/src/skills/sonamu-migration/SKILL.md +0 -316
- package/src/skills/sonamu-naite/SKILL.md +0 -266
- package/src/skills/sonamu-query/SKILL.md +0 -48
- package/src/skills/sonamu-query/references/model-patterns.md +0 -390
- package/src/skills/sonamu-query/references/model.md +0 -366
- package/src/skills/sonamu-query/references/puri.md +0 -413
- package/src/skills/sonamu-query/references/search.md +0 -238
- package/src/skills/sonamu-query/references/upsert.md +0 -324
- package/src/skills/sonamu-tasks/SKILL.md +0 -236
- package/src/skills/sonamu-testing/SKILL.md +0 -251
- package/src/skills/sonamu-testing/references/devrunner.md +0 -405
- package/src/skills/sonamu-testing/references/helpers.md +0 -185
- package/src/skills/sonamu-testing/references/patterns.md +0 -263
- package/src/skills/sonamu-testing/references/pitfalls.md +0 -588
- package/src/skills/sonamu-testing/references/quick-start.md +0 -285
- package/src/skills/sonamu-testing/references/type-safety.md +0 -172
- package/src/skills/sonamu-testing/references/writing-plan.md +0 -375
- package/src/skills/sonamu-vector/SKILL.md +0 -222
|
@@ -1,205 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: sonamu-ai-agents
|
|
3
|
-
description: Builds tool-using AI agents on Sonamu. Use when implementing an agent class, defining its tools, or managing per-request agent state. Covers BaseAgentClass, the @tools decorator, ToolLoopAgent, and AsyncLocalStorage state.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# AI Agent Guide
|
|
7
|
-
|
|
8
|
-
Sonamu provides a framework that wraps Vercel AI SDK's `ToolLoopAgent` to build class-based AI Agents.
|
|
9
|
-
|
|
10
|
-
**Source code:** `modules/sonamu/src/ai/agents/`
|
|
11
|
-
|
|
12
|
-
---
|
|
13
|
-
|
|
14
|
-
## Structure
|
|
15
|
-
|
|
16
|
-
| File | Role |
|
|
17
|
-
| ---------- | ----------------------------------------------------------------------- |
|
|
18
|
-
| `agent.ts` | `BaseAgentClass`, `tools` decorator |
|
|
19
|
-
| `types.ts` | `AgentConfig`, `ToolDecoratorOptions`, `RegisteredToolDefinition`, etc. |
|
|
20
|
-
|
|
21
|
-
---
|
|
22
|
-
|
|
23
|
-
## BaseAgentClass
|
|
24
|
-
|
|
25
|
-
The base class for Agents. Extend it to create a custom Agent.
|
|
26
|
-
|
|
27
|
-
```typescript
|
|
28
|
-
import { BaseAgentClass, tools } from "sonamu/ai/agents";
|
|
29
|
-
import { z } from "zod/v4";
|
|
30
|
-
|
|
31
|
-
class MyAgentClass extends BaseAgentClass<{ count: number }> {
|
|
32
|
-
constructor() {
|
|
33
|
-
super("MyAgent"); // agentName (used as logger category)
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
@tools({
|
|
37
|
-
description: "Adds two numbers",
|
|
38
|
-
schema: {
|
|
39
|
-
input: z.object({ a: z.number(), b: z.number() }),
|
|
40
|
-
output: z.object({ result: z.number() }),
|
|
41
|
-
},
|
|
42
|
-
})
|
|
43
|
-
async add(input: { a: number; b: number }) {
|
|
44
|
-
return { result: input.a + input.b };
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
export const MyAgent = new MyAgentClass();
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
### Key Features
|
|
52
|
-
|
|
53
|
-
| Feature | Description |
|
|
54
|
-
| ------------- | ------------------------------------------- |
|
|
55
|
-
| `this.logger` | LogTape logger (agent category) |
|
|
56
|
-
| `this.store` | AsyncLocalStorage-based state access |
|
|
57
|
-
| `this.tools` | Registered toolset (ToolSet) |
|
|
58
|
-
| `this.use()` | Run the Agent (ALS context + ToolLoopAgent) |
|
|
59
|
-
|
|
60
|
-
---
|
|
61
|
-
|
|
62
|
-
## @tools Decorator
|
|
63
|
-
|
|
64
|
-
Registers a method as an AI tool. Define input/output using Zod v4 schema.
|
|
65
|
-
|
|
66
|
-
```typescript
|
|
67
|
-
@tools({
|
|
68
|
-
name?: string, // Tool name (default: "className.methodName" format)
|
|
69
|
-
description?: string, // Description shown to the LLM
|
|
70
|
-
schema: {
|
|
71
|
-
input: z.ZodType, // Input schema (required)
|
|
72
|
-
output?: z.ZodType, // Output schema (optional)
|
|
73
|
-
},
|
|
74
|
-
needsApproval?: boolean | function, // Whether user approval is required
|
|
75
|
-
toModelOutput?: function, // Transform output returned to the model
|
|
76
|
-
providerOptions?: ProviderOptions, // Provider-specific options
|
|
77
|
-
})
|
|
78
|
-
```
|
|
79
|
-
|
|
80
|
-
### Automatic Name Generation Rule
|
|
81
|
-
|
|
82
|
-
If `name` is omitted, it is auto-generated as `{ModelName(camelCase)}.{methodName(camelCase)}`.
|
|
83
|
-
|
|
84
|
-
```typescript
|
|
85
|
-
class SearchAgentClass extends BaseAgentClass<...> {
|
|
86
|
-
@tools({ ... })
|
|
87
|
-
async findDocuments(input: ...) { ... }
|
|
88
|
-
// → Tool name: "searchAgent.findDocuments"
|
|
89
|
-
}
|
|
90
|
-
```
|
|
91
|
-
|
|
92
|
-
The suffixes `Class`, `Model`, and `Frame` are automatically stripped from the class name.
|
|
93
|
-
|
|
94
|
-
---
|
|
95
|
-
|
|
96
|
-
## Running an Agent (use)
|
|
97
|
-
|
|
98
|
-
Run the Agent with the `use()` method. ToolLoopAgent operates within the AsyncLocalStorage context.
|
|
99
|
-
|
|
100
|
-
```typescript
|
|
101
|
-
import { anthropic } from "@ai-sdk/anthropic";
|
|
102
|
-
|
|
103
|
-
const result = await MyAgent.use(
|
|
104
|
-
// AgentConfig
|
|
105
|
-
{
|
|
106
|
-
model: anthropic("claude-sonnet-4-6"),
|
|
107
|
-
instructions: "You are a math assistant.",
|
|
108
|
-
toolChoice: "auto", // "auto" | "none" | "required"
|
|
109
|
-
maxOutputTokens: 1000,
|
|
110
|
-
temperature: 0.7,
|
|
111
|
-
},
|
|
112
|
-
// Initial state (stored in AsyncLocalStorage)
|
|
113
|
-
{ count: 0 },
|
|
114
|
-
// Callback (receives Agent instance)
|
|
115
|
-
async (agent) => {
|
|
116
|
-
// agent is a ToolLoopAgent instance
|
|
117
|
-
// Use Vercel AI SDK's agent API
|
|
118
|
-
return agent;
|
|
119
|
-
},
|
|
120
|
-
);
|
|
121
|
-
```
|
|
122
|
-
|
|
123
|
-
### AgentConfig Options
|
|
124
|
-
|
|
125
|
-
| Option | Type | Description |
|
|
126
|
-
| -------------------------------------- | -------------------------------- | ------------------------------------ |
|
|
127
|
-
| `model` | `LanguageModel` | AI SDK model (required) |
|
|
128
|
-
| `instructions` | `string` | System prompt |
|
|
129
|
-
| `toolChoice` | `"auto" \| "none" \| "required"` | Tool selection strategy |
|
|
130
|
-
| `stopWhen` | `StopCondition` | Stop condition |
|
|
131
|
-
| `activeTools` | `string[]` | List of tool names to activate |
|
|
132
|
-
| `maxOutputTokens` | `number` | Maximum output tokens |
|
|
133
|
-
| `temperature` | `number` | Temperature |
|
|
134
|
-
| `topP` / `topK` | `number` | Sampling parameters |
|
|
135
|
-
| `presencePenalty` / `frequencyPenalty` | `number` | Penalties |
|
|
136
|
-
| `seed` | `number` | Seed for reproducibility |
|
|
137
|
-
| `stopSequences` | `string[]` | Generation stop sequences |
|
|
138
|
-
| `providerOptions` | `ProviderOptions` | Additional provider-specific options |
|
|
139
|
-
| `headers` | `Record<string, string>` | Custom HTTP headers |
|
|
140
|
-
|
|
141
|
-
---
|
|
142
|
-
|
|
143
|
-
## State Management (AsyncLocalStorage)
|
|
144
|
-
|
|
145
|
-
`BaseAgentClass` defines the state type with the generic `TStore`. When you pass the initial state to `use()`, it can be accessed via `this.store` during tool execution.
|
|
146
|
-
|
|
147
|
-
```typescript
|
|
148
|
-
class StatefulAgentClass extends BaseAgentClass<{ processedItems: string[] }> {
|
|
149
|
-
@tools({ ... })
|
|
150
|
-
async processItem(input: { item: string }) {
|
|
151
|
-
// Access state
|
|
152
|
-
this.store?.processedItems.push(input.item);
|
|
153
|
-
return { ok: true };
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
```
|
|
157
|
-
|
|
158
|
-
**Note:** `this.store` is `undefined` outside of a `use()` context.
|
|
159
|
-
|
|
160
|
-
---
|
|
161
|
-
|
|
162
|
-
## Tool Isolation
|
|
163
|
-
|
|
164
|
-
Tools for each Agent class are isolated per class. The `toolSet` getter filters by `def.from === this.constructor.name`.
|
|
165
|
-
|
|
166
|
-
```typescript
|
|
167
|
-
class AgentA extends BaseAgentClass<void> {
|
|
168
|
-
@tools({ ... }) async toolX() { ... }
|
|
169
|
-
}
|
|
170
|
-
class AgentB extends BaseAgentClass<void> {
|
|
171
|
-
@tools({ ... }) async toolY() { ... }
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// AgentA.tools → { contains only toolX }
|
|
175
|
-
// AgentB.tools → { contains only toolY }
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
---
|
|
179
|
-
|
|
180
|
-
## Logging
|
|
181
|
-
|
|
182
|
-
`this.logger` uses LogTape. The category is generated with `convertDomainToCategory(agentName, "agent")`.
|
|
183
|
-
|
|
184
|
-
Debug logs are automatically recorded on tool execution:
|
|
185
|
-
|
|
186
|
-
```
|
|
187
|
-
tools: {model}.{method} with args: {args}
|
|
188
|
-
```
|
|
189
|
-
|
|
190
|
-
---
|
|
191
|
-
|
|
192
|
-
## Related Packages
|
|
193
|
-
|
|
194
|
-
- `ai`: Vercel AI SDK (`ToolLoopAgent`, `Agent`, `ToolSet`)
|
|
195
|
-
- `@ai-sdk/provider-utils`: `tool()`, `Tool`, `ToolExecutionOptions`
|
|
196
|
-
- `zod/v4`: Schema definitions
|
|
197
|
-
- `@logtape/logtape`: Logging
|
|
198
|
-
|
|
199
|
-
---
|
|
200
|
-
|
|
201
|
-
## References
|
|
202
|
-
|
|
203
|
-
- **Source code**: `modules/sonamu/src/ai/agents/`
|
|
204
|
-
- **Vercel AI SDK**: https://sdk.vercel.ai/docs
|
|
205
|
-
- **Vector search**: `sonamu-vector`
|
|
@@ -1,480 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
name: sonamu-api
|
|
3
|
-
description: Exposes Model methods as HTTP endpoints with the @api decorator. Use when adding or changing an API endpoint, choosing httpMethod/guards/clients options, or implementing a file upload. Covers @api, @upload, response subset selection, and the generated service client.
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
# @api Decorator
|
|
7
|
-
|
|
8
|
-
## Basic Usage
|
|
9
|
-
|
|
10
|
-
```typescript
|
|
11
|
-
@api({ httpMethod: "GET" })
|
|
12
|
-
async findById(id: number): Promise<User> { }
|
|
13
|
-
// → GET /user/findById?id=1
|
|
14
|
-
```
|
|
15
|
-
|
|
16
|
-
## Options
|
|
17
|
-
|
|
18
|
-
| Option | Description | Default |
|
|
19
|
-
| -------------- | ------------------------------------------------------- | ------------------- |
|
|
20
|
-
| `httpMethod` | GET, POST, PUT, DELETE, PATCH | GET |
|
|
21
|
-
| `clients` | Client types to generate | `["axios"]` |
|
|
22
|
-
| `resourceName` | queryKey for TanStack Query | - |
|
|
23
|
-
| `guards` | Authentication/authorization guards | - |
|
|
24
|
-
| `path` | Custom path | `/{model}/{method}` |
|
|
25
|
-
| `description` | API description (for documentation) | - |
|
|
26
|
-
| `timeout` | Request timeout (ms) | - |
|
|
27
|
-
| `contentType` | Response Content-Type | `application/json` |
|
|
28
|
-
| `cacheControl` | Cache-Control header setting | - |
|
|
29
|
-
| `compress` | Response compression setting (can disable with `false`) | - |
|
|
30
|
-
|
|
31
|
-
## clients Options
|
|
32
|
-
|
|
33
|
-
| Client | Purpose |
|
|
34
|
-
| ----------------------------- | ------------------------ |
|
|
35
|
-
| `axios` | General API calls |
|
|
36
|
-
| `axios-multipart` | File upload (axios) |
|
|
37
|
-
| `tanstack-query` | Query hook for reads |
|
|
38
|
-
| `tanstack-mutation` | Mutation hook for writes |
|
|
39
|
-
| `tanstack-mutation-multipart` | File upload Mutation |
|
|
40
|
-
| `window-fetch` | Browser fetch API |
|
|
41
|
-
|
|
42
|
-
## Pattern Examples
|
|
43
|
-
|
|
44
|
-
### Read API
|
|
45
|
-
|
|
46
|
-
```typescript
|
|
47
|
-
@api({
|
|
48
|
-
httpMethod: "GET",
|
|
49
|
-
clients: ["axios", "tanstack-query"],
|
|
50
|
-
resourceName: "Users",
|
|
51
|
-
})
|
|
52
|
-
async findMany(params: UserListParams): Promise<ListResult<User>> { }
|
|
53
|
-
```
|
|
54
|
-
|
|
55
|
-
### Write API
|
|
56
|
-
|
|
57
|
-
```typescript
|
|
58
|
-
@api({
|
|
59
|
-
httpMethod: "POST",
|
|
60
|
-
clients: ["axios", "tanstack-mutation"],
|
|
61
|
-
})
|
|
62
|
-
async save(params: UserSaveParams[]): Promise<number[]> { }
|
|
63
|
-
```
|
|
64
|
-
|
|
65
|
-
### API Requiring Authorization
|
|
66
|
-
|
|
67
|
-
```typescript
|
|
68
|
-
@api({ httpMethod: "POST", guards: ["admin"] })
|
|
69
|
-
async del(ids: number[]): Promise<number> { }
|
|
70
|
-
```
|
|
71
|
-
|
|
72
|
-
## Context Access
|
|
73
|
-
|
|
74
|
-
```typescript
|
|
75
|
-
import { Sonamu } from "sonamu";
|
|
76
|
-
|
|
77
|
-
@api({ httpMethod: "GET", guards: ["user"] })
|
|
78
|
-
async me(): Promise<User | null> {
|
|
79
|
-
const { user } = Sonamu.getContext();
|
|
80
|
-
return user ? this.findById("A", user.id) : null;
|
|
81
|
-
}
|
|
82
|
-
```
|
|
83
|
-
|
|
84
|
-
| Context Property | Description |
|
|
85
|
-
| ---------------- | ------------------------------------------------------------------- |
|
|
86
|
-
| `user` | Authenticated user (better-auth User, null if unauthenticated) |
|
|
87
|
-
| `session` | Current session info (better-auth Session, null if unauthenticated) |
|
|
88
|
-
| `request` | FastifyRequest |
|
|
89
|
-
| `reply` | FastifyReply |
|
|
90
|
-
| `headers` | HTTP request headers |
|
|
91
|
-
| `bufferedFiles` | Buffer mode uploaded files |
|
|
92
|
-
| `uploadedFiles` | Stream mode uploaded files |
|
|
93
|
-
| `locale` | Request locale |
|
|
94
|
-
|
|
95
|
-
## File Upload (@upload)
|
|
96
|
-
|
|
97
|
-
> **CRITICAL: `@upload` is used standalone without `@api`.**
|
|
98
|
-
> Adding `@upload` **automatically generates** a POST endpoint and `axios-multipart`/`tanstack-mutation-multipart` clients.
|
|
99
|
-
> Adding `@api` alongside it causes a **build error** due to `checkSingleDecorator` conflict.
|
|
100
|
-
|
|
101
|
-
```typescript
|
|
102
|
-
// CORRECT
|
|
103
|
-
@upload({ limits: { files: 10 }, guards: ["user"] })
|
|
104
|
-
async upload(...): Promise<number[]> { }
|
|
105
|
-
|
|
106
|
-
// WRONG — causes build error
|
|
107
|
-
@api({ httpMethod: "POST", clients: ["axios-multipart"] })
|
|
108
|
-
@upload({ limits: { files: 10 } })
|
|
109
|
-
async upload(...): Promise<number[]> { }
|
|
110
|
-
```
|
|
111
|
-
|
|
112
|
-
**`@upload` supported options** (`httpMethod`, `clients` are not supported — set automatically)
|
|
113
|
-
|
|
114
|
-
| Option | Description |
|
|
115
|
-
| -------------- | --------------------------------------------------- |
|
|
116
|
-
| `guards` | Authentication/authorization guards |
|
|
117
|
-
| `limits` | File count/size limits (`{ files: N }`) |
|
|
118
|
-
| `consume` | `"buffer"` (default) or `"stream"` |
|
|
119
|
-
| `description` | API documentation description |
|
|
120
|
-
| `destination` | Stream mode only: storage driver key |
|
|
121
|
-
| `keyGenerator` | Stream mode only: function to generate storage path |
|
|
122
|
-
|
|
123
|
-
### Parameter Rule: Must Wrap in a Single Object
|
|
124
|
-
|
|
125
|
-
> **CRITICAL: If an `@upload` method has 2 or more parameters, they must be wrapped into a single object.**
|
|
126
|
-
>
|
|
127
|
-
> Using multiple primitive parameters causes a codegen bug in `services.template.ts` that generates `useUploadMutation` incorrectly.
|
|
128
|
-
|
|
129
|
-
```typescript
|
|
130
|
-
// WRONG — codegen breaks (missing mutationFn argument)
|
|
131
|
-
async upload(entity_type: string, entity_id: number, file_type: string)
|
|
132
|
-
|
|
133
|
-
// CORRECT — wrap in a single object
|
|
134
|
-
async upload(params: { entity_type: string; entity_id: number; file_type: string })
|
|
135
|
-
```
|
|
136
|
-
|
|
137
|
-
Call site pattern:
|
|
138
|
-
|
|
139
|
-
```typescript
|
|
140
|
-
uploadMutation.mutate({
|
|
141
|
-
params: { entity_type, entity_id, file_type },
|
|
142
|
-
files,
|
|
143
|
-
});
|
|
144
|
-
```
|
|
145
|
-
|
|
146
|
-
> Root cause: a `split(":")` bug in `services.template.ts` makes `useUploadMutation` drop
|
|
147
|
-
> every primitive parameter after the first, so multiple primitives must be wrapped in one object.
|
|
148
|
-
|
|
149
|
-
### Buffer Mode (Default)
|
|
150
|
-
|
|
151
|
-
```typescript
|
|
152
|
-
@upload({ limits: { files: 10 } })
|
|
153
|
-
async uploadFiles(): Promise<{ files: SonamuFile[] }> {
|
|
154
|
-
const { bufferedFiles } = Sonamu.getContext();
|
|
155
|
-
// Access file data via bufferedFiles[].buffer
|
|
156
|
-
}
|
|
157
|
-
```
|
|
158
|
-
|
|
159
|
-
### Stream Mode (Large Files)
|
|
160
|
-
|
|
161
|
-
```typescript
|
|
162
|
-
@upload({
|
|
163
|
-
consume: "stream",
|
|
164
|
-
destination: "s3", // or "fs"
|
|
165
|
-
keyGenerator: (file) => `uploads/${Date.now()}-${file.filename}`,
|
|
166
|
-
limits: { files: 5 },
|
|
167
|
-
})
|
|
168
|
-
async uploadLargeFiles(): Promise<{ urls: string[] }> {
|
|
169
|
-
const { uploadedFiles } = Sonamu.getContext();
|
|
170
|
-
// Access stored path via uploadedFiles[].key
|
|
171
|
-
}
|
|
172
|
-
```
|
|
173
|
-
|
|
174
|
-
---
|
|
175
|
-
|
|
176
|
-
## Real-world Business Logic Patterns
|
|
177
|
-
|
|
178
|
-
### Transaction with History Logging
|
|
179
|
-
|
|
180
|
-
Pattern for atomically handling main data and history together when changing state:
|
|
181
|
-
|
|
182
|
-
```typescript
|
|
183
|
-
// consultation.model.ts
|
|
184
|
-
|
|
185
|
-
@api({ httpMethod: "POST", guards: ["user"] })
|
|
186
|
-
async changeStatus(
|
|
187
|
-
id: number,
|
|
188
|
-
status: ConsultationStatus,
|
|
189
|
-
memo?: string
|
|
190
|
-
): Promise<Consultation> {
|
|
191
|
-
const wdb = this.getPuri("w");
|
|
192
|
-
|
|
193
|
-
return wdb.transaction(async (trx) => {
|
|
194
|
-
// 1. Update consultation
|
|
195
|
-
await trx.ubRegister("consultations", {
|
|
196
|
-
id,
|
|
197
|
-
status,
|
|
198
|
-
updated_at: new Date()
|
|
199
|
-
});
|
|
200
|
-
await trx.ubUpsert("consultations");
|
|
201
|
-
|
|
202
|
-
// 2. Record status change history
|
|
203
|
-
await trx.ubRegister("consultation_histories", {
|
|
204
|
-
consultation_id: id,
|
|
205
|
-
status,
|
|
206
|
-
memo,
|
|
207
|
-
created_at: new Date(),
|
|
208
|
-
});
|
|
209
|
-
await trx.ubUpsert("consultation_histories");
|
|
210
|
-
|
|
211
|
-
// 3. Return result
|
|
212
|
-
return this.findById("A", id);
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
```
|
|
216
|
-
|
|
217
|
-
**Key points:**
|
|
218
|
-
|
|
219
|
-
- Atomicity guaranteed by transaction
|
|
220
|
-
- ubRegister + ubUpsert pattern
|
|
221
|
-
- Return latest data after change
|
|
222
|
-
|
|
223
|
-
### Validation Logic and Business Rules
|
|
224
|
-
|
|
225
|
-
Pattern for complex validation such as duplicate checks and capacity checks before registration:
|
|
226
|
-
|
|
227
|
-
```typescript
|
|
228
|
-
@api({ httpMethod: "POST", guards: ["user"] })
|
|
229
|
-
async enroll(
|
|
230
|
-
courseId: number,
|
|
231
|
-
userId: number
|
|
232
|
-
): Promise<Enrollment> {
|
|
233
|
-
// 1. Prevent duplicate registration
|
|
234
|
-
const existing = await this.findOne("A", {
|
|
235
|
-
course_id: courseId,
|
|
236
|
-
user_id: userId,
|
|
237
|
-
});
|
|
238
|
-
|
|
239
|
-
if (existing) {
|
|
240
|
-
throw new Error("Already enrolled in this course");
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
// 2. Check capacity
|
|
244
|
-
const course = await CourseModel.findById("A", courseId);
|
|
245
|
-
const { total } = await this.findMany({ course_id: courseId });
|
|
246
|
-
|
|
247
|
-
if (total >= course.max_students) {
|
|
248
|
-
throw new Error("Course is at capacity");
|
|
249
|
-
}
|
|
250
|
-
|
|
251
|
-
// 3. Enroll
|
|
252
|
-
const [id] = await this.save([{ course_id: courseId, user_id: userId }]);
|
|
253
|
-
return this.findById("A", id);
|
|
254
|
-
}
|
|
255
|
-
```
|
|
256
|
-
|
|
257
|
-
**Key points:**
|
|
258
|
-
|
|
259
|
-
- Step-by-step validation (duplicate → capacity)
|
|
260
|
-
- Clear error messages
|
|
261
|
-
- Save after validation passes
|
|
262
|
-
|
|
263
|
-
### Using Authorization Guards
|
|
264
|
-
|
|
265
|
-
Access control based on user role:
|
|
266
|
-
|
|
267
|
-
```typescript
|
|
268
|
-
// Regular user only
|
|
269
|
-
@api({ httpMethod: "POST", guards: ["user"] })
|
|
270
|
-
async save(spa: PostSaveParams[]): Promise<number[]> { }
|
|
271
|
-
|
|
272
|
-
// Admin only
|
|
273
|
-
@api({ httpMethod: "POST", guards: ["admin"] })
|
|
274
|
-
async del(ids: number[]): Promise<number> { }
|
|
275
|
-
|
|
276
|
-
// Using currently logged-in user info
|
|
277
|
-
@api({ httpMethod: "GET", guards: ["user"] })
|
|
278
|
-
async myConsultations(): Promise<ListResult<Consultation>> {
|
|
279
|
-
const { user } = Sonamu.getContext();
|
|
280
|
-
return this.findMany({ user_id: user!.id });
|
|
281
|
-
}
|
|
282
|
-
```
|
|
283
|
-
|
|
284
|
-
### Writing API Tests
|
|
285
|
-
|
|
286
|
-
Validating custom APIs in Business Logic tests:
|
|
287
|
-
|
|
288
|
-
```typescript
|
|
289
|
-
// consultation.test.ts
|
|
290
|
-
describe("E. Business Logic", () => {
|
|
291
|
-
test("Status change API", async () => {
|
|
292
|
-
const { consultationId } = await createTestConsultationWithDeps();
|
|
293
|
-
|
|
294
|
-
// Call custom API
|
|
295
|
-
const updated = await ConsultationModel.changeStatus(
|
|
296
|
-
consultationId,
|
|
297
|
-
"completed",
|
|
298
|
-
"Consultation complete",
|
|
299
|
-
);
|
|
300
|
-
|
|
301
|
-
expect(updated.status).toBe("completed");
|
|
302
|
-
|
|
303
|
-
// Verify history was recorded
|
|
304
|
-
const histories = await ConsultationHistoryModel.findMany({
|
|
305
|
-
consultation_id: consultationId,
|
|
306
|
-
});
|
|
307
|
-
expect(histories.rows).toHaveLength(1);
|
|
308
|
-
});
|
|
309
|
-
|
|
310
|
-
test("Enrollment validation", async () => {
|
|
311
|
-
const courseId = 1;
|
|
312
|
-
const userId = 1;
|
|
313
|
-
|
|
314
|
-
// First enrollment succeeds
|
|
315
|
-
await EnrollmentModel.enroll(courseId, userId);
|
|
316
|
-
|
|
317
|
-
// Duplicate enrollment fails
|
|
318
|
-
await expect(EnrollmentModel.enroll(courseId, userId)).rejects.toThrow(
|
|
319
|
-
"Already enrolled in this course",
|
|
320
|
-
);
|
|
321
|
-
});
|
|
322
|
-
});
|
|
323
|
-
```
|
|
324
|
-
|
|
325
|
-
---
|
|
326
|
-
|
|
327
|
-
## Conventions and Best Practices
|
|
328
|
-
|
|
329
|
-
### Error Message Pattern
|
|
330
|
-
|
|
331
|
-
Use `this.modelName` and the `SD()` function for consistent error messages.
|
|
332
|
-
|
|
333
|
-
**BAD: Hardcoded model name**
|
|
334
|
-
|
|
335
|
-
```typescript
|
|
336
|
-
// findById
|
|
337
|
-
if (!rows[0]) {
|
|
338
|
-
throw new NotFoundException(SD("error.entityNotFound")("Department", id));
|
|
339
|
-
}
|
|
340
|
-
|
|
341
|
-
// findMany
|
|
342
|
-
throw new BadRequestException(SD("error.unknownSearchField")(params.search));
|
|
343
|
-
```
|
|
344
|
-
|
|
345
|
-
**GOOD: Using this.modelName**
|
|
346
|
-
|
|
347
|
-
```typescript
|
|
348
|
-
// findById - auto-detects model name
|
|
349
|
-
if (!rows[0]) {
|
|
350
|
-
throw new NotFoundException(SD("notFound")(this.modelName, id));
|
|
351
|
-
}
|
|
352
|
-
|
|
353
|
-
// findMany - short and clear key
|
|
354
|
-
throw new BadRequestException(SD("search.invalidField")(params.search));
|
|
355
|
-
```
|
|
356
|
-
|
|
357
|
-
**Benefits:**
|
|
358
|
-
|
|
359
|
-
- DRY principle: model name managed in one place
|
|
360
|
-
- Refactoring safe: error messages auto-reflect model name changes
|
|
361
|
-
- Short i18n keys: `notFound`, `search.invalidField` are more concise
|
|
362
|
-
|
|
363
|
-
### satisfies Keyword
|
|
364
|
-
|
|
365
|
-
Use TypeScript's satisfies keyword to preserve type inference while checking types.
|
|
366
|
-
|
|
367
|
-
**BAD: Loss of type inference**
|
|
368
|
-
|
|
369
|
-
```typescript
|
|
370
|
-
const params: RoleListParams = {
|
|
371
|
-
num: 24,
|
|
372
|
-
page: 1,
|
|
373
|
-
search: "id" as const,
|
|
374
|
-
orderBy: "id-desc" as const,
|
|
375
|
-
...rawParams,
|
|
376
|
-
};
|
|
377
|
-
```
|
|
378
|
-
|
|
379
|
-
**GOOD: Type check + preserved inference with satisfies**
|
|
380
|
-
|
|
381
|
-
```typescript
|
|
382
|
-
const params = {
|
|
383
|
-
num: 24,
|
|
384
|
-
page: 1,
|
|
385
|
-
search: "id" as const,
|
|
386
|
-
orderBy: "id-desc" as const,
|
|
387
|
-
...rawParams,
|
|
388
|
-
} satisfies RoleListParams;
|
|
389
|
-
```
|
|
390
|
-
|
|
391
|
-
**Benefits:**
|
|
392
|
-
|
|
393
|
-
- Compile-time verification: checks that params satisfies the RoleListParams type
|
|
394
|
-
- Preserved type inference: params keeps its narrowed type
|
|
395
|
-
- Better IDE support: more accurate autocomplete and type checking
|
|
396
|
-
|
|
397
|
-
### debug Option
|
|
398
|
-
|
|
399
|
-
The debug option in executeSubsetQuery defaults to false, so it does not need to be specified explicitly.
|
|
400
|
-
|
|
401
|
-
**BAD: Unnecessary debug: false**
|
|
402
|
-
|
|
403
|
-
```typescript
|
|
404
|
-
return this.executeSubsetQuery({
|
|
405
|
-
subset,
|
|
406
|
-
qb,
|
|
407
|
-
params,
|
|
408
|
-
enhancers,
|
|
409
|
-
debug: false, // unnecessary — it's the default
|
|
410
|
-
});
|
|
411
|
-
```
|
|
412
|
-
|
|
413
|
-
**GOOD: Use the default**
|
|
414
|
-
|
|
415
|
-
```typescript
|
|
416
|
-
return this.executeSubsetQuery({
|
|
417
|
-
subset,
|
|
418
|
-
qb,
|
|
419
|
-
params,
|
|
420
|
-
enhancers,
|
|
421
|
-
});
|
|
422
|
-
```
|
|
423
|
-
|
|
424
|
-
**When to use debug: true:**
|
|
425
|
-
|
|
426
|
-
```typescript
|
|
427
|
-
// Only specify when debugging
|
|
428
|
-
return this.executeSubsetQuery({
|
|
429
|
-
subset,
|
|
430
|
-
qb,
|
|
431
|
-
params,
|
|
432
|
-
debug: true, // Print SQL query log
|
|
433
|
-
});
|
|
434
|
-
```
|
|
435
|
-
|
|
436
|
-
## @stream Decorator (SSE)
|
|
437
|
-
|
|
438
|
-
Creates a Server-Sent Events endpoint.
|
|
439
|
-
|
|
440
|
-
```typescript
|
|
441
|
-
import { stream } from "sonamu";
|
|
442
|
-
import { z } from "zod";
|
|
443
|
-
|
|
444
|
-
@stream({
|
|
445
|
-
type: "sse",
|
|
446
|
-
events: z.object({
|
|
447
|
-
progress: z.object({ percent: z.number() }),
|
|
448
|
-
done: z.object({ result: z.string() }),
|
|
449
|
-
}),
|
|
450
|
-
guards: ["user"],
|
|
451
|
-
})
|
|
452
|
-
async processStream() { ... }
|
|
453
|
-
```
|
|
454
|
-
|
|
455
|
-
| Option | Description | Required |
|
|
456
|
-
| -------------- | ---------------------------------------------- | -------- |
|
|
457
|
-
| `type` | `"sse"` (only SSE currently supported) | Yes |
|
|
458
|
-
| `events` | Define event keys and payloads with Zod schema | Yes |
|
|
459
|
-
| `path` | Custom path | - |
|
|
460
|
-
| `resourceName` | Resource name | - |
|
|
461
|
-
| `guards` | Authentication/authorization guards | - |
|
|
462
|
-
|
|
463
|
-
## @transactional Decorator
|
|
464
|
-
|
|
465
|
-
Wraps the entire method in an automatic transaction. Reuses an existing transaction context if one is already active.
|
|
466
|
-
|
|
467
|
-
```typescript
|
|
468
|
-
import { transactional } from "sonamu";
|
|
469
|
-
|
|
470
|
-
@transactional({ isolation: "serializable" })
|
|
471
|
-
async transferFunds(fromId: number, toId: number, amount: number) {
|
|
472
|
-
// this.getPuri("w") automatically runs inside the transaction
|
|
473
|
-
}
|
|
474
|
-
```
|
|
475
|
-
|
|
476
|
-
| Option | Description | Default |
|
|
477
|
-
| ----------- | ------------------------------------------------------------------------------------------ | ------- |
|
|
478
|
-
| `isolation` | Transaction isolation level (read uncommitted/read committed/repeatable read/serializable) | - |
|
|
479
|
-
| `readOnly` | Read-only transaction | `false` |
|
|
480
|
-
| `dbPreset` | DB preset | `"w"` |
|