howone 0.1.11 → 0.1.13
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/package.json +1 -1
- package/templates/vite/.howone/skills/howone-sdk/SKILL.md +53 -63
- package/templates/vite/.howone/skills/howone-sdk/references/01-client-setup.md +278 -0
- package/templates/vite/.howone/skills/howone-sdk/references/02-entity-operations.md +379 -0
- package/templates/vite/.howone/skills/howone-sdk/references/03-ai-actions.md +489 -0
- package/templates/vite/.howone/skills/howone-sdk/references/04-auth.md +484 -0
- package/templates/vite/.howone/skills/howone-sdk/references/05-file-upload.md +319 -0
- package/templates/vite/.howone/skills/howone-sdk/references/06-react-integration.md +394 -0
- package/templates/vite/.howone/skills/howone-sdk/references/07-raw-http.md +299 -0
- package/templates/vite/.howone/skills/howone-sdk/references/08-manifest-codegen.md +400 -0
- package/templates/vite/package.json +1 -1
- package/templates/vite/.howone/skills/howone-sdk/references/usage-patterns.md +0 -215
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
# Raw HTTP
|
|
2
|
+
|
|
3
|
+
## When to Use
|
|
4
|
+
|
|
5
|
+
Use `client.raw` when you need to call a custom backend endpoint that is **not** covered by `client.entities` or `client.ai`. The raw client is an Axios-based HTTP client that automatically attaches the HowOne auth token and project ID headers.
|
|
6
|
+
|
|
7
|
+
**Do not use `client.raw` to re-implement entity operations or AI workflows** — use the typed SDK methods instead.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## The RawHttpClient Interface
|
|
12
|
+
|
|
13
|
+
```ts
|
|
14
|
+
type RawHttpClient = {
|
|
15
|
+
instance: AxiosInstance
|
|
16
|
+
|
|
17
|
+
// All methods return Promise<AxiosResponse>
|
|
18
|
+
request(config: RequestConfig): Promise<AxiosResponse>
|
|
19
|
+
get(config: RequestConfig): Promise<AxiosResponse>
|
|
20
|
+
post(config: RequestConfig): Promise<AxiosResponse>
|
|
21
|
+
put(config: RequestConfig): Promise<AxiosResponse>
|
|
22
|
+
patch(config: RequestConfig): Promise<AxiosResponse>
|
|
23
|
+
delete(config: RequestConfig): Promise<AxiosResponse>
|
|
24
|
+
|
|
25
|
+
// Cancel an in-flight request by URL
|
|
26
|
+
cancelRequest(url: string): void
|
|
27
|
+
|
|
28
|
+
// Cancel all in-flight requests
|
|
29
|
+
cancelAllRequests(): void
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
### RequestConfig
|
|
34
|
+
|
|
35
|
+
`RequestConfig` extends `AxiosRequestConfig` with optional interceptors:
|
|
36
|
+
|
|
37
|
+
```ts
|
|
38
|
+
type RequestConfig<T = AxiosResponse> = AxiosRequestConfig & {
|
|
39
|
+
interceptors?: {
|
|
40
|
+
requestInterceptor?: (config: InternalAxiosRequestConfig) => InternalAxiosRequestConfig
|
|
41
|
+
requestInterceptorCatch?: (error: any) => any
|
|
42
|
+
responseInterceptor?: (res: T) => T
|
|
43
|
+
responseInterceptorCatch?: (error: any) => any
|
|
44
|
+
}
|
|
45
|
+
showLoading?: boolean
|
|
46
|
+
}
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
---
|
|
50
|
+
|
|
51
|
+
## Basic Usage
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import howone from '@/lib/sdk'
|
|
55
|
+
|
|
56
|
+
// GET
|
|
57
|
+
const response = await howone.raw.get({
|
|
58
|
+
url: '/api/custom/stats',
|
|
59
|
+
})
|
|
60
|
+
const data = response.data // untyped AxiosResponse.data
|
|
61
|
+
|
|
62
|
+
// POST with body
|
|
63
|
+
const response = await howone.raw.post({
|
|
64
|
+
url: '/api/custom/send-notification',
|
|
65
|
+
data: {
|
|
66
|
+
userId: '123',
|
|
67
|
+
message: 'Hello!',
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
// PUT
|
|
72
|
+
const response = await howone.raw.put({
|
|
73
|
+
url: `/api/custom/profile/${userId}`,
|
|
74
|
+
data: { displayName: 'Alice' },
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
// PATCH
|
|
78
|
+
const response = await howone.raw.patch({
|
|
79
|
+
url: `/api/custom/settings`,
|
|
80
|
+
data: { theme: 'dark' },
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
// DELETE
|
|
84
|
+
const response = await howone.raw.delete({
|
|
85
|
+
url: `/api/custom/sessions/${sessionId}`,
|
|
86
|
+
})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
---
|
|
90
|
+
|
|
91
|
+
## Typed Responses
|
|
92
|
+
|
|
93
|
+
Wrap with generics for type safety:
|
|
94
|
+
|
|
95
|
+
```ts
|
|
96
|
+
type StatsResponse = {
|
|
97
|
+
totalUsers: number
|
|
98
|
+
activeToday: number
|
|
99
|
+
storageUsed: number
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const response = await howone.raw.get<StatsResponse>({
|
|
103
|
+
url: '/api/custom/stats',
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const stats = response.data // typed as StatsResponse
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
## Query Parameters
|
|
112
|
+
|
|
113
|
+
```ts
|
|
114
|
+
// Pass query params via the `params` field (Axios serializes them automatically)
|
|
115
|
+
const response = await howone.raw.get({
|
|
116
|
+
url: '/api/custom/search',
|
|
117
|
+
params: {
|
|
118
|
+
q: 'dragons',
|
|
119
|
+
page: 1,
|
|
120
|
+
limit: 20,
|
|
121
|
+
sort: 'createdAt',
|
|
122
|
+
order: 'desc',
|
|
123
|
+
},
|
|
124
|
+
})
|
|
125
|
+
// Calls: GET /api/custom/search?q=dragons&page=1&limit=20&sort=createdAt&order=desc
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
---
|
|
129
|
+
|
|
130
|
+
## Custom Headers
|
|
131
|
+
|
|
132
|
+
```ts
|
|
133
|
+
const response = await howone.raw.post({
|
|
134
|
+
url: '/api/custom/webhook',
|
|
135
|
+
data: payload,
|
|
136
|
+
headers: {
|
|
137
|
+
'X-Webhook-Secret': import.meta.env.VITE_WEBHOOK_SECRET,
|
|
138
|
+
'X-Request-Source': 'app',
|
|
139
|
+
},
|
|
140
|
+
})
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
---
|
|
144
|
+
|
|
145
|
+
## Request Cancellation
|
|
146
|
+
|
|
147
|
+
```ts
|
|
148
|
+
// Cancel a specific request by URL
|
|
149
|
+
howone.raw.cancelRequest('/api/custom/long-running')
|
|
150
|
+
|
|
151
|
+
// Cancel all in-flight requests (e.g. on page unmount)
|
|
152
|
+
howone.raw.cancelAllRequests()
|
|
153
|
+
|
|
154
|
+
// Pattern: cancel on component unmount
|
|
155
|
+
import { useEffect } from 'react'
|
|
156
|
+
import howone from '@/lib/sdk'
|
|
157
|
+
|
|
158
|
+
function DataComponent() {
|
|
159
|
+
useEffect(() => {
|
|
160
|
+
howone.raw.get({ url: '/api/custom/data' })
|
|
161
|
+
.then(res => setData(res.data))
|
|
162
|
+
|
|
163
|
+
return () => {
|
|
164
|
+
howone.raw.cancelRequest('/api/custom/data')
|
|
165
|
+
}
|
|
166
|
+
}, [])
|
|
167
|
+
}
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
---
|
|
171
|
+
|
|
172
|
+
## Per-Request Interceptors
|
|
173
|
+
|
|
174
|
+
For one-off request/response transforms without modifying the global client:
|
|
175
|
+
|
|
176
|
+
```ts
|
|
177
|
+
const response = await howone.raw.post({
|
|
178
|
+
url: '/api/custom/transform',
|
|
179
|
+
data: payload,
|
|
180
|
+
interceptors: {
|
|
181
|
+
requestInterceptor: (config) => {
|
|
182
|
+
// Modify request config (e.g. add timestamp)
|
|
183
|
+
config.headers['X-Timestamp'] = Date.now().toString()
|
|
184
|
+
return config
|
|
185
|
+
},
|
|
186
|
+
responseInterceptor: (res) => {
|
|
187
|
+
// Log response time or transform data
|
|
188
|
+
console.log('Response status:', res.status)
|
|
189
|
+
return res
|
|
190
|
+
},
|
|
191
|
+
responseInterceptorCatch: (error) => {
|
|
192
|
+
// Handle specific error codes
|
|
193
|
+
if (error.response?.status === 503) {
|
|
194
|
+
console.error('Service temporarily unavailable')
|
|
195
|
+
}
|
|
196
|
+
return Promise.reject(error)
|
|
197
|
+
},
|
|
198
|
+
},
|
|
199
|
+
})
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
---
|
|
203
|
+
|
|
204
|
+
## Direct Axios Instance Access
|
|
205
|
+
|
|
206
|
+
For maximum control (multipart forms, streaming responses, etc.):
|
|
207
|
+
|
|
208
|
+
```ts
|
|
209
|
+
const instance = howone.raw.instance // Axios instance
|
|
210
|
+
|
|
211
|
+
// Multipart form data
|
|
212
|
+
const formData = new FormData()
|
|
213
|
+
formData.append('report', file)
|
|
214
|
+
formData.append('meta', JSON.stringify({ type: 'monthly' }))
|
|
215
|
+
|
|
216
|
+
const response = await instance.post('/api/custom/reports', formData, {
|
|
217
|
+
headers: { 'Content-Type': 'multipart/form-data' },
|
|
218
|
+
onUploadProgress: (e) => {
|
|
219
|
+
const percent = Math.round((e.loaded * 100) / (e.total ?? 1))
|
|
220
|
+
setProgress(percent)
|
|
221
|
+
},
|
|
222
|
+
})
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## React Pattern: Data Fetching with Raw HTTP
|
|
228
|
+
|
|
229
|
+
```tsx
|
|
230
|
+
import { useEffect, useState } from 'react'
|
|
231
|
+
import howone from '@/lib/sdk'
|
|
232
|
+
|
|
233
|
+
type AnalyticsData = {
|
|
234
|
+
views: number
|
|
235
|
+
clicks: number
|
|
236
|
+
conversions: number
|
|
237
|
+
period: string
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function Analytics({ projectId }: { projectId: string }) {
|
|
241
|
+
const [data, setData] = useState<AnalyticsData | null>(null)
|
|
242
|
+
const [loading, setLoading] = useState(true)
|
|
243
|
+
const [error, setError] = useState<string | null>(null)
|
|
244
|
+
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
let cancelled = false
|
|
247
|
+
|
|
248
|
+
howone.raw.get<AnalyticsData>({
|
|
249
|
+
url: '/api/custom/analytics',
|
|
250
|
+
params: { projectId, period: '30d' },
|
|
251
|
+
})
|
|
252
|
+
.then(res => { if (!cancelled) setData(res.data) })
|
|
253
|
+
.catch(err => { if (!cancelled) setError(err.message) })
|
|
254
|
+
.finally(() => { if (!cancelled) setLoading(false) })
|
|
255
|
+
|
|
256
|
+
return () => {
|
|
257
|
+
cancelled = true
|
|
258
|
+
howone.raw.cancelRequest('/api/custom/analytics')
|
|
259
|
+
}
|
|
260
|
+
}, [projectId])
|
|
261
|
+
|
|
262
|
+
if (loading) return <div>Loading analytics...</div>
|
|
263
|
+
if (error) return <div>Error: {error}</div>
|
|
264
|
+
if (!data) return null
|
|
265
|
+
|
|
266
|
+
return (
|
|
267
|
+
<div>
|
|
268
|
+
<p>Views: {data.views}</p>
|
|
269
|
+
<p>Clicks: {data.clicks}</p>
|
|
270
|
+
<p>Conversions: {data.conversions}</p>
|
|
271
|
+
</div>
|
|
272
|
+
)
|
|
273
|
+
}
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## client.raw vs client.entities
|
|
279
|
+
|
|
280
|
+
| Use Case | Recommended API |
|
|
281
|
+
|---|---|
|
|
282
|
+
| CRUD on a HowOne entity | `howone.entities.<Entity>.*` |
|
|
283
|
+
| Querying with pagination/filter/sort | `howone.entities.<Entity>.query()` |
|
|
284
|
+
| Running an AI workflow | `howone.ai.<action>.run()` |
|
|
285
|
+
| Calling a custom backend route | `howone.raw.get/post/...` |
|
|
286
|
+
| Sending webhooks or notifications | `howone.raw.post()` |
|
|
287
|
+
| Fetching analytics or aggregated data not in entities | `howone.raw.get()` |
|
|
288
|
+
| Uploading files | `howone.upload.*` |
|
|
289
|
+
|
|
290
|
+
---
|
|
291
|
+
|
|
292
|
+
## Common Mistakes
|
|
293
|
+
|
|
294
|
+
| Mistake | Correct Pattern |
|
|
295
|
+
|---|---|
|
|
296
|
+
| `howone.raw.get({ url: '/entities/Story' })` to read entities | Use `howone.entities.Story.query()` |
|
|
297
|
+
| Not handling Axios errors (`.response.status`) | Wrap in try/catch and check `error.response?.status` |
|
|
298
|
+
| Calling `cancelAllRequests()` too broadly | Use `cancelRequest(url)` for surgical cancellation |
|
|
299
|
+
| Forgetting that `response.data` is untyped by default | Pass the type parameter: `howone.raw.get<MyType>(...)` |
|
|
@@ -0,0 +1,400 @@
|
|
|
1
|
+
# Manifest Codegen
|
|
2
|
+
|
|
3
|
+
## Overview
|
|
4
|
+
|
|
5
|
+
HowOne apps are driven by two backend-synced manifests:
|
|
6
|
+
|
|
7
|
+
| File | Contents | Drives |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| `.howone/database/manifest.json` | Entity names, fields, types | Entity type definitions + `client.entity<...>` bindings |
|
|
10
|
+
| `.howone/ai/manifest.json` | AI action IDs, input/output JSON schemas | zod schemas + `defineAiAction` bindings |
|
|
11
|
+
|
|
12
|
+
**The coding agent should always generate `src/lib/sdk.ts` from these manifest files, not from memory or assumptions.**
|
|
13
|
+
|
|
14
|
+
Sync tools (`sync_schema_artifacts`, `sync_ai_artifacts`) write the manifests. The coding agent reads the manifests and writes `src/lib/sdk.ts`.
|
|
15
|
+
|
|
16
|
+
---
|
|
17
|
+
|
|
18
|
+
## Reading `.howone/database/manifest.json`
|
|
19
|
+
|
|
20
|
+
### Example manifest
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"version": "1",
|
|
25
|
+
"entities": [
|
|
26
|
+
{
|
|
27
|
+
"name": "Story",
|
|
28
|
+
"fields": [
|
|
29
|
+
{ "name": "title", "type": "string", "required": true },
|
|
30
|
+
{ "name": "content", "type": "text", "required": true },
|
|
31
|
+
{ "name": "authorId", "type": "string", "required": true },
|
|
32
|
+
{ "name": "status", "type": "string", "required": true, "enum": ["draft", "published", "archived"] },
|
|
33
|
+
{ "name": "wordCount", "type": "integer", "required": true },
|
|
34
|
+
{ "name": "tags", "type": "array", "items": "string", "required": false },
|
|
35
|
+
{ "name": "coverUrl", "type": "string", "required": false }
|
|
36
|
+
]
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"name": "Comment",
|
|
40
|
+
"fields": [
|
|
41
|
+
{ "name": "storyId", "type": "string", "required": true },
|
|
42
|
+
{ "name": "authorId", "type": "string", "required": true },
|
|
43
|
+
{ "name": "body", "type": "text", "required": true },
|
|
44
|
+
{ "name": "likes", "type": "integer", "required": false }
|
|
45
|
+
]
|
|
46
|
+
}
|
|
47
|
+
]
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
### Field type → TypeScript type mapping
|
|
52
|
+
|
|
53
|
+
| Manifest type | TypeScript type |
|
|
54
|
+
|---|---|
|
|
55
|
+
| `string` | `string` |
|
|
56
|
+
| `text` | `string` |
|
|
57
|
+
| `integer` | `number` |
|
|
58
|
+
| `number` / `float` | `number` |
|
|
59
|
+
| `boolean` | `boolean` |
|
|
60
|
+
| `date` / `datetime` | `string` (ISO 8601) |
|
|
61
|
+
| `array` (items: string) | `string[]` |
|
|
62
|
+
| `array` (items: object) | `Record<string, unknown>[]` or inline type |
|
|
63
|
+
| `object` | `Record<string, unknown>` |
|
|
64
|
+
| `enum` | `'value1' \| 'value2' \| ...` |
|
|
65
|
+
|
|
66
|
+
- Fields in `required: true` are non-optional in `Record` and `Create` types.
|
|
67
|
+
- Fields in `required: false` (or absent from required list) get `?` in `Record` and are optional in `Create`.
|
|
68
|
+
|
|
69
|
+
### Generated TypeScript from the example manifest
|
|
70
|
+
|
|
71
|
+
```ts
|
|
72
|
+
import { type EntityRecord } from '@howone/sdk'
|
|
73
|
+
|
|
74
|
+
// ── Story ─────────────────────────────────────────────────────
|
|
75
|
+
export type StoryRecord = EntityRecord & {
|
|
76
|
+
title: string
|
|
77
|
+
content: string
|
|
78
|
+
authorId: string
|
|
79
|
+
status: 'draft' | 'published' | 'archived'
|
|
80
|
+
wordCount: number
|
|
81
|
+
tags?: string[]
|
|
82
|
+
coverUrl?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export type StoryCreate = {
|
|
86
|
+
title: string
|
|
87
|
+
content: string
|
|
88
|
+
authorId: string
|
|
89
|
+
status: 'draft' | 'published' | 'archived'
|
|
90
|
+
wordCount: number
|
|
91
|
+
tags?: string[]
|
|
92
|
+
coverUrl?: string
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export type StoryUpdate = Partial<StoryCreate>
|
|
96
|
+
|
|
97
|
+
// ── Comment ───────────────────────────────────────────────────
|
|
98
|
+
export type CommentRecord = EntityRecord & {
|
|
99
|
+
storyId: string
|
|
100
|
+
authorId: string
|
|
101
|
+
body: string
|
|
102
|
+
likes?: number
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export type CommentCreate = {
|
|
106
|
+
storyId: string
|
|
107
|
+
authorId: string
|
|
108
|
+
body: string
|
|
109
|
+
likes?: number
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export type CommentUpdate = Partial<CommentCreate>
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
---
|
|
116
|
+
|
|
117
|
+
## Reading `.howone/ai/manifest.json`
|
|
118
|
+
|
|
119
|
+
### Example manifest
|
|
120
|
+
|
|
121
|
+
```json
|
|
122
|
+
{
|
|
123
|
+
"version": "1",
|
|
124
|
+
"actions": [
|
|
125
|
+
{
|
|
126
|
+
"id": "generateStory",
|
|
127
|
+
"name": "Generate Story",
|
|
128
|
+
"inputSchema": {
|
|
129
|
+
"type": "object",
|
|
130
|
+
"properties": {
|
|
131
|
+
"topic": { "type": "string" },
|
|
132
|
+
"ageRange": { "type": "string", "enum": ["3-5", "6-8", "9-12"] },
|
|
133
|
+
"language": { "type": "string" }
|
|
134
|
+
},
|
|
135
|
+
"required": ["topic", "ageRange"]
|
|
136
|
+
},
|
|
137
|
+
"outputSchema": {
|
|
138
|
+
"type": "object",
|
|
139
|
+
"properties": {
|
|
140
|
+
"title": { "type": "string" },
|
|
141
|
+
"content": { "type": "string" },
|
|
142
|
+
"summary": { "type": "string" }
|
|
143
|
+
},
|
|
144
|
+
"required": ["title", "content"]
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
{
|
|
148
|
+
"id": "translateText",
|
|
149
|
+
"name": "Translate Text",
|
|
150
|
+
"inputSchema": {
|
|
151
|
+
"type": "object",
|
|
152
|
+
"properties": {
|
|
153
|
+
"text": { "type": "string" },
|
|
154
|
+
"targetLang": { "type": "string" },
|
|
155
|
+
"formality": { "type": "string", "enum": ["formal", "informal"] }
|
|
156
|
+
},
|
|
157
|
+
"required": ["text", "targetLang"]
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
]
|
|
161
|
+
}
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
### JSON Schema → zod mapping
|
|
165
|
+
|
|
166
|
+
| JSON Schema | zod |
|
|
167
|
+
|---|---|
|
|
168
|
+
| `{ "type": "string" }` | `z.string()` |
|
|
169
|
+
| `{ "type": "number" }` | `z.number()` |
|
|
170
|
+
| `{ "type": "integer" }` | `z.number().int()` |
|
|
171
|
+
| `{ "type": "boolean" }` | `z.boolean()` |
|
|
172
|
+
| `{ "type": "string", "enum": ["a","b"] }` | `z.enum(['a', 'b'])` |
|
|
173
|
+
| `{ "type": "array", "items": { "type": "string" } }` | `z.array(z.string())` |
|
|
174
|
+
| `{ "type": "object", "properties": { ... } }` | `z.object({ ... })` |
|
|
175
|
+
| Field NOT in `required[]` | append `.optional()` |
|
|
176
|
+
|
|
177
|
+
### Generated zod schemas from the example manifest
|
|
178
|
+
|
|
179
|
+
```ts
|
|
180
|
+
import { z } from 'zod'
|
|
181
|
+
|
|
182
|
+
// ── generateStory ─────────────────────────────────────────────
|
|
183
|
+
export const generateStoryInputSchema = z.object({
|
|
184
|
+
topic: z.string(),
|
|
185
|
+
ageRange: z.enum(['3-5', '6-8', '9-12']),
|
|
186
|
+
language: z.string().optional(),
|
|
187
|
+
})
|
|
188
|
+
export type GenerateStoryInput = z.infer<typeof generateStoryInputSchema>
|
|
189
|
+
|
|
190
|
+
// Output type for manual unwrapping (do NOT put in defineAiAction as outputSchema)
|
|
191
|
+
export type GenerateStoryOutput = {
|
|
192
|
+
title: string
|
|
193
|
+
content: string
|
|
194
|
+
summary?: string
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── translateText ─────────────────────────────────────────────
|
|
198
|
+
export const translateTextInputSchema = z.object({
|
|
199
|
+
text: z.string(),
|
|
200
|
+
targetLang: z.string(),
|
|
201
|
+
formality: z.enum(['formal', 'informal']).optional(),
|
|
202
|
+
})
|
|
203
|
+
export type TranslateTextInput = z.infer<typeof translateTextInputSchema>
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
---
|
|
207
|
+
|
|
208
|
+
## Full Generated `src/lib/sdk.ts`
|
|
209
|
+
|
|
210
|
+
Combining both manifests from the examples above:
|
|
211
|
+
|
|
212
|
+
```ts
|
|
213
|
+
// src/lib/sdk.ts
|
|
214
|
+
// Generated from .howone/database/manifest.json and .howone/ai/manifest.json
|
|
215
|
+
import {
|
|
216
|
+
createClient,
|
|
217
|
+
defineAiAction,
|
|
218
|
+
defineAiActions,
|
|
219
|
+
defineEntities,
|
|
220
|
+
type EntityRecord,
|
|
221
|
+
withAiActions,
|
|
222
|
+
withEntities,
|
|
223
|
+
} from '@howone/sdk'
|
|
224
|
+
import { z } from 'zod'
|
|
225
|
+
|
|
226
|
+
// ═══════════════════════════════════════════════════════════════
|
|
227
|
+
// ENTITY TYPES
|
|
228
|
+
// ═══════════════════════════════════════════════════════════════
|
|
229
|
+
|
|
230
|
+
export type StoryRecord = EntityRecord & {
|
|
231
|
+
title: string
|
|
232
|
+
content: string
|
|
233
|
+
authorId: string
|
|
234
|
+
status: 'draft' | 'published' | 'archived'
|
|
235
|
+
wordCount: number
|
|
236
|
+
tags?: string[]
|
|
237
|
+
coverUrl?: string
|
|
238
|
+
}
|
|
239
|
+
export type StoryCreate = {
|
|
240
|
+
title: string
|
|
241
|
+
content: string
|
|
242
|
+
authorId: string
|
|
243
|
+
status: 'draft' | 'published' | 'archived'
|
|
244
|
+
wordCount: number
|
|
245
|
+
tags?: string[]
|
|
246
|
+
coverUrl?: string
|
|
247
|
+
}
|
|
248
|
+
export type StoryUpdate = Partial<StoryCreate>
|
|
249
|
+
|
|
250
|
+
export type CommentRecord = EntityRecord & {
|
|
251
|
+
storyId: string
|
|
252
|
+
authorId: string
|
|
253
|
+
body: string
|
|
254
|
+
likes?: number
|
|
255
|
+
}
|
|
256
|
+
export type CommentCreate = {
|
|
257
|
+
storyId: string
|
|
258
|
+
authorId: string
|
|
259
|
+
body: string
|
|
260
|
+
likes?: number
|
|
261
|
+
}
|
|
262
|
+
export type CommentUpdate = Partial<CommentCreate>
|
|
263
|
+
|
|
264
|
+
// ═══════════════════════════════════════════════════════════════
|
|
265
|
+
// AI SCHEMAS & TYPES
|
|
266
|
+
// ═══════════════════════════════════════════════════════════════
|
|
267
|
+
|
|
268
|
+
export const generateStoryInputSchema = z.object({
|
|
269
|
+
topic: z.string(),
|
|
270
|
+
ageRange: z.enum(['3-5', '6-8', '9-12']),
|
|
271
|
+
language: z.string().optional(),
|
|
272
|
+
})
|
|
273
|
+
export type GenerateStoryInput = z.infer<typeof generateStoryInputSchema>
|
|
274
|
+
export type GenerateStoryOutput = {
|
|
275
|
+
title: string
|
|
276
|
+
content: string
|
|
277
|
+
summary?: string
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export const translateTextInputSchema = z.object({
|
|
281
|
+
text: z.string(),
|
|
282
|
+
targetLang: z.string(),
|
|
283
|
+
formality: z.enum(['formal', 'informal']).optional(),
|
|
284
|
+
})
|
|
285
|
+
export type TranslateTextInput = z.infer<typeof translateTextInputSchema>
|
|
286
|
+
export type TranslateTextOutput = {
|
|
287
|
+
translatedText: string
|
|
288
|
+
detectedLang?: string
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ═══════════════════════════════════════════════════════════════
|
|
292
|
+
// CLIENT
|
|
293
|
+
// ═══════════════════════════════════════════════════════════════
|
|
294
|
+
|
|
295
|
+
const client = createClient({
|
|
296
|
+
projectId: import.meta.env.VITE_HOWONE_PROJECT_ID,
|
|
297
|
+
env: import.meta.env.VITE_HOWONE_ENV,
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
// ═══════════════════════════════════════════════════════════════
|
|
301
|
+
// ENTITY BINDINGS
|
|
302
|
+
// ═══════════════════════════════════════════════════════════════
|
|
303
|
+
|
|
304
|
+
export const entities = defineEntities({
|
|
305
|
+
Story: client.entity<StoryRecord, StoryCreate, StoryUpdate>('Story'),
|
|
306
|
+
Comment: client.entity<CommentRecord, CommentCreate, CommentUpdate>('Comment'),
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
// ═══════════════════════════════════════════════════════════════
|
|
310
|
+
// AI ACTION BINDINGS
|
|
311
|
+
// ═══════════════════════════════════════════════════════════════
|
|
312
|
+
|
|
313
|
+
export const ai = defineAiActions({
|
|
314
|
+
generateStory: defineAiAction('generateStory', {
|
|
315
|
+
inputSchema: generateStoryInputSchema,
|
|
316
|
+
}),
|
|
317
|
+
translateText: defineAiAction('translateText', {
|
|
318
|
+
inputSchema: translateTextInputSchema,
|
|
319
|
+
}),
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
// ═══════════════════════════════════════════════════════════════
|
|
323
|
+
// COMPOSED CLIENT
|
|
324
|
+
// ═══════════════════════════════════════════════════════════════
|
|
325
|
+
|
|
326
|
+
const howone = withAiActions(withEntities(client, entities), ai)
|
|
327
|
+
export default howone
|
|
328
|
+
```
|
|
329
|
+
|
|
330
|
+
---
|
|
331
|
+
|
|
332
|
+
## Codegen Checklist
|
|
333
|
+
|
|
334
|
+
Before finalising generated code, verify:
|
|
335
|
+
|
|
336
|
+
- [ ] Every entity from `.howone/database/manifest.json` has a `Record`, `Create`, and `Update` type
|
|
337
|
+
- [ ] `Create` types are defined **explicitly** (not via `Omit`)
|
|
338
|
+
- [ ] Optional fields match `required: false` in the manifest
|
|
339
|
+
- [ ] Every AI action from `.howone/ai/manifest.json` has an `inputSchema` zod object
|
|
340
|
+
- [ ] Required input fields are not `.optional()` in zod
|
|
341
|
+
- [ ] AI action names match the manifest `id` exactly (case-sensitive)
|
|
342
|
+
- [ ] `createClient` uses `import.meta.env.*` only
|
|
343
|
+
- [ ] `withEntities` is applied before `withAiActions` in the composition chain
|
|
344
|
+
- [ ] No generated source files are placed under `.howone/`
|
|
345
|
+
- [ ] Exported types and schemas are importable from `@/lib/sdk`
|
|
346
|
+
|
|
347
|
+
---
|
|
348
|
+
|
|
349
|
+
## Incremental Update Pattern
|
|
350
|
+
|
|
351
|
+
When new entities or actions are added to the manifests, update `src/lib/sdk.ts` by:
|
|
352
|
+
|
|
353
|
+
1. Reading the current `src/lib/sdk.ts` to preserve existing bindings and import style.
|
|
354
|
+
2. Appending new types, schemas, entity bindings, and action bindings.
|
|
355
|
+
3. Not removing existing bindings unless the manifest explicitly removed them.
|
|
356
|
+
4. Preserving export names for backward compatibility with existing UI code.
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
// Before (existing)
|
|
360
|
+
export const entities = defineEntities({
|
|
361
|
+
Story: client.entity<StoryRecord, StoryCreate, StoryUpdate>('Story'),
|
|
362
|
+
})
|
|
363
|
+
|
|
364
|
+
// After (added Comment entity from new manifest)
|
|
365
|
+
export const entities = defineEntities({
|
|
366
|
+
Story: client.entity<StoryRecord, StoryCreate, StoryUpdate>('Story'),
|
|
367
|
+
Comment: client.entity<CommentRecord, CommentCreate, CommentUpdate>('Comment'),
|
|
368
|
+
})
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
---
|
|
372
|
+
|
|
373
|
+
## AI-First Persistence Pattern
|
|
374
|
+
|
|
375
|
+
When the app generates data with AI and saves it to an entity:
|
|
376
|
+
|
|
377
|
+
1. Read `.howone/ai/manifest.json` → determine AI output shape.
|
|
378
|
+
2. Define entity fields that mirror the AI output fields.
|
|
379
|
+
3. Add any app-specific metadata fields (status, userId, createdAt, etc.).
|
|
380
|
+
4. Generate the entity type, then the AI action binding.
|
|
381
|
+
|
|
382
|
+
```ts
|
|
383
|
+
// AI generates: { title: string, content: string, summary: string }
|
|
384
|
+
// Save it to Story entity which adds: authorId, status, wordCount
|
|
385
|
+
|
|
386
|
+
async function generateAndSave(input: GenerateStoryInput, authorId: string) {
|
|
387
|
+
const result = await howone.ai.generateStory.run(input)
|
|
388
|
+
if (!result.success) throw new Error(result.errors.join(', '))
|
|
389
|
+
|
|
390
|
+
const output = result.finalResult as GenerateStoryOutput
|
|
391
|
+
|
|
392
|
+
return howone.entities.Story.create({
|
|
393
|
+
title: output.title,
|
|
394
|
+
content: output.content,
|
|
395
|
+
authorId,
|
|
396
|
+
status: 'draft',
|
|
397
|
+
wordCount: output.content.split(' ').length,
|
|
398
|
+
})
|
|
399
|
+
}
|
|
400
|
+
```
|