howone 0.1.23 → 0.1.25
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/01-architect/01-app-generation.md +180 -91
- package/templates/vite/.howone/skills/howone-sdk/01-architect/02-manifest-codegen.md +67 -4
- package/templates/vite/.howone/skills/howone-sdk/02-database/01-schema-design.md +463 -69
- package/templates/vite/.howone/skills/howone-sdk/02-database/02-schema-operations.md +366 -64
- package/templates/vite/.howone/skills/howone-sdk/02-database/03-data-access-patterns.md +204 -67
- package/templates/vite/.howone/skills/howone-sdk/02-database/04-query-dsl-and-responses.md +237 -0
- package/templates/vite/.howone/skills/howone-sdk/02-database/05-ai-persistence-patterns.md +372 -0
- package/templates/vite/.howone/skills/howone-sdk/03-sdk/01-client-setup.md +58 -36
- package/templates/vite/.howone/skills/howone-sdk/03-sdk/02-entity-operations.md +67 -0
- package/templates/vite/.howone/skills/howone-sdk/03-sdk/03-auth.md +267 -469
- package/templates/vite/.howone/skills/howone-sdk/03-sdk/04-react-integration.md +113 -320
- package/templates/vite/.howone/skills/howone-sdk/03-sdk/07-ai-action-calls.md +66 -16
- package/templates/vite/.howone/skills/howone-sdk/03-sdk/08-extension-boundaries.md +226 -0
- package/templates/vite/.howone/skills/howone-sdk/04-ai/01-ai-capability-architecture.md +159 -96
- package/templates/vite/.howone/skills/howone-sdk/04-ai/02-workflow-contract-rules.md +353 -96
- package/templates/vite/.howone/skills/howone-sdk/04-ai/03-ai-sdk-handoff.md +181 -42
- package/templates/vite/.howone/skills/howone-sdk/04-ai/04-service-capability-catalog.md +281 -0
- package/templates/vite/.howone/skills/howone-sdk/04-ai/05-workflow-operations.md +256 -0
- package/templates/vite/.howone/skills/howone-sdk/04-ai/06-ai-feature-playbooks.md +296 -0
- package/templates/vite/.howone/skills/howone-sdk/SKILL.md +29 -12
- package/templates/vite/.howone/skills/howone-sdk/agents/openai.yaml +2 -2
- package/templates/vite/package.json +1 -1
- package/templates/vite/.howone/skills/howone-sdk/04-ai/.gitkeep +0 -1
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
# AI Persistence Patterns
|
|
2
|
+
|
|
3
|
+
Use this reference when an AI workflow output must become durable product data: generation history,
|
|
4
|
+
saved results, analysis reports, retryable jobs, share pages, or user libraries.
|
|
5
|
+
|
|
6
|
+
AI workflow contracts describe **how to produce an output**. Entity schemas describe **what the app
|
|
7
|
+
persists, lists, edits, shares, and reloads**. Do not merge those two responsibilities.
|
|
8
|
+
|
|
9
|
+
## Core Rule
|
|
10
|
+
|
|
11
|
+
```text
|
|
12
|
+
AI capability outputSchema != database entity schema
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
The output schema is the workflow return contract. It can contain transient execution details,
|
|
16
|
+
intermediate data, model metadata, or provider-shaped structures. The database schema is a product
|
|
17
|
+
contract. It should store only fields that the app needs after refresh, across sessions, or on public
|
|
18
|
+
pages.
|
|
19
|
+
|
|
20
|
+
For every AI feature, ask:
|
|
21
|
+
|
|
22
|
+
| Question | Persist? | Where |
|
|
23
|
+
|---|---:|---|
|
|
24
|
+
| Must the user see this after refresh? | yes | Entity field |
|
|
25
|
+
| Must it appear in history/library/search? | yes | Entity field + index if queried |
|
|
26
|
+
| Is it only needed while streaming/running? | no | Local UI state / runtime event |
|
|
27
|
+
| Is it provider debug data? | usually no | Logs, not entity data |
|
|
28
|
+
| Is it needed to retry or resume? | yes | Entity field |
|
|
29
|
+
| Is it sensitive model/provider metadata? | usually no | Avoid public entity fields |
|
|
30
|
+
|
|
31
|
+
## Recommended Flow
|
|
32
|
+
|
|
33
|
+
For long-running or user-visible AI operations, create a pending record before calling the workflow.
|
|
34
|
+
|
|
35
|
+
```ts
|
|
36
|
+
import { runAiActionAndPersist } from '@howone/sdk'
|
|
37
|
+
|
|
38
|
+
await runAiActionAndPersist({
|
|
39
|
+
entity: howone.entities.Generation,
|
|
40
|
+
input: { prompt },
|
|
41
|
+
createPending: (input) => ({
|
|
42
|
+
prompt: input.prompt,
|
|
43
|
+
status: 'pending',
|
|
44
|
+
requestedAt: new Date().toISOString(),
|
|
45
|
+
}),
|
|
46
|
+
run: (input) => howone.ai.generateImage.run(input),
|
|
47
|
+
mapCompleted: ({ output }) => ({
|
|
48
|
+
status: 'completed',
|
|
49
|
+
resultUrl: output.imageUrl,
|
|
50
|
+
completedAt: new Date().toISOString(),
|
|
51
|
+
}),
|
|
52
|
+
mapFailed: ({ error }) => ({
|
|
53
|
+
status: 'failed',
|
|
54
|
+
errorMessage: error instanceof Error ? error.message : 'Generation failed',
|
|
55
|
+
}),
|
|
56
|
+
})
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Why pending-first:
|
|
60
|
+
|
|
61
|
+
- refresh can show an in-progress item instead of losing the request;
|
|
62
|
+
- failure can be displayed in history;
|
|
63
|
+
- retry can reuse the original prompt/options;
|
|
64
|
+
- support/debug can identify which input produced the failed state;
|
|
65
|
+
- UI can render from persisted data instead of assuming local state survived.
|
|
66
|
+
|
|
67
|
+
For very fast, disposable AI actions, persistence may be unnecessary. Do not create an entity just
|
|
68
|
+
because an AI workflow exists.
|
|
69
|
+
|
|
70
|
+
## Status Fields
|
|
71
|
+
|
|
72
|
+
Every persisted AI job/history entity should have a status field.
|
|
73
|
+
|
|
74
|
+
Recommended:
|
|
75
|
+
|
|
76
|
+
```json
|
|
77
|
+
{
|
|
78
|
+
"status": {
|
|
79
|
+
"type": "string",
|
|
80
|
+
"description": "pending | running | completed | failed | canceled",
|
|
81
|
+
"default": "pending"
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Use stable string values:
|
|
87
|
+
|
|
88
|
+
| Status | Meaning |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `pending` | Record exists, workflow has not produced final output. |
|
|
91
|
+
| `running` | Optional if the app receives a running state after submission. |
|
|
92
|
+
| `completed` | Output fields are valid for display. |
|
|
93
|
+
| `failed` | `errorMessage` or failure fields explain the failure. |
|
|
94
|
+
| `canceled` | User/system canceled and no final output should be expected. |
|
|
95
|
+
|
|
96
|
+
Rules:
|
|
97
|
+
|
|
98
|
+
- Do not infer completion only from `resultUrl` or another output field.
|
|
99
|
+
- Keep failed records when the product has history or retry UX.
|
|
100
|
+
- If the UI shows a spinner from persisted data, it must also handle stale `pending/running` records.
|
|
101
|
+
|
|
102
|
+
## Minimal Generation History Schema
|
|
103
|
+
|
|
104
|
+
Use for image/text/report/music/video generation history.
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"name": "Generation",
|
|
109
|
+
"type": "object",
|
|
110
|
+
"visibility": "private",
|
|
111
|
+
"properties": {
|
|
112
|
+
"prompt": { "type": "string" },
|
|
113
|
+
"status": { "type": "string", "default": "pending" },
|
|
114
|
+
"resultUrl": { "type": ["string", "null"], "default": null },
|
|
115
|
+
"resultText": { "type": ["string", "null"], "default": null },
|
|
116
|
+
"errorMessage": { "type": ["string", "null"], "default": null },
|
|
117
|
+
"requestedAt": { "type": "date" },
|
|
118
|
+
"completedAt": { "type": ["date", "null"], "default": null }
|
|
119
|
+
},
|
|
120
|
+
"required": ["prompt", "status", "requestedAt"],
|
|
121
|
+
"access": {
|
|
122
|
+
"authenticated": { "read": "own", "create": "own", "update": "own", "delete": "own" },
|
|
123
|
+
"public": { "read": "none", "create": "none", "update": "none", "delete": "none" }
|
|
124
|
+
},
|
|
125
|
+
"indexes": [
|
|
126
|
+
{ "name": "owner_updated", "fields": ["updatedDate"], "scope": "owner" },
|
|
127
|
+
{ "name": "owner_status_updated", "fields": ["status", "updatedDate"], "scope": "owner" }
|
|
128
|
+
],
|
|
129
|
+
"performance": {
|
|
130
|
+
"defaultLimit": 20,
|
|
131
|
+
"maxLimit": 100,
|
|
132
|
+
"allowedSorts": ["updatedDate", "requestedAt"]
|
|
133
|
+
},
|
|
134
|
+
"presentation": {
|
|
135
|
+
"titleField": "prompt",
|
|
136
|
+
"subtitleField": "status"
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
Use separate result fields instead of one opaque `result` object when the UI lists or filters them.
|
|
142
|
+
Use an object field only for genuinely nested, product-level structured results.
|
|
143
|
+
|
|
144
|
+
## Analysis Report Schema
|
|
145
|
+
|
|
146
|
+
Use when AI returns a structured report that users browse later.
|
|
147
|
+
|
|
148
|
+
```json
|
|
149
|
+
{
|
|
150
|
+
"name": "AnalysisReport",
|
|
151
|
+
"type": "object",
|
|
152
|
+
"visibility": "private",
|
|
153
|
+
"properties": {
|
|
154
|
+
"sourceTitle": { "type": "string" },
|
|
155
|
+
"sourceUrl": { "type": ["string", "null"], "default": null },
|
|
156
|
+
"status": { "type": "string", "default": "pending" },
|
|
157
|
+
"summary": { "type": ["string", "null"], "default": null },
|
|
158
|
+
"insights": { "type": "array", "default": [] },
|
|
159
|
+
"score": { "type": ["number", "null"], "default": null },
|
|
160
|
+
"errorMessage": { "type": ["string", "null"], "default": null },
|
|
161
|
+
"requestedAt": { "type": "date" },
|
|
162
|
+
"completedAt": { "type": ["date", "null"], "default": null }
|
|
163
|
+
},
|
|
164
|
+
"required": ["sourceTitle", "status", "requestedAt"],
|
|
165
|
+
"access": {
|
|
166
|
+
"authenticated": { "read": "own", "create": "own", "update": "own", "delete": "own" },
|
|
167
|
+
"public": { "read": "none", "create": "none", "update": "none", "delete": "none" }
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
Mapping rule:
|
|
173
|
+
|
|
174
|
+
```ts
|
|
175
|
+
const output = await howone.ai.analyzeDocument.run(input)
|
|
176
|
+
|
|
177
|
+
await howone.entities.AnalysisReport.update(report.id, {
|
|
178
|
+
status: 'completed',
|
|
179
|
+
summary: output.summary,
|
|
180
|
+
insights: output.insights,
|
|
181
|
+
score: output.score,
|
|
182
|
+
completedAt: new Date().toISOString(),
|
|
183
|
+
})
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
Do not save the whole workflow envelope unless the entity has an explicitly designed field for that
|
|
187
|
+
envelope and the product needs it.
|
|
188
|
+
|
|
189
|
+
## Public AI Result Share Page
|
|
190
|
+
|
|
191
|
+
Use when a user can share one generated result publicly.
|
|
192
|
+
|
|
193
|
+
Recommended split:
|
|
194
|
+
|
|
195
|
+
- private `Generation` entity for user history and edits;
|
|
196
|
+
- public or scoped `SharedGeneration` entity for anonymous viewing.
|
|
197
|
+
|
|
198
|
+
Why split:
|
|
199
|
+
|
|
200
|
+
- private history may include prompts, failures, drafts, and internal metadata;
|
|
201
|
+
- public page should expose only curated fields;
|
|
202
|
+
- public access rules stay simple and auditable;
|
|
203
|
+
- unsharing can delete or deactivate the shared record without destroying private history.
|
|
204
|
+
|
|
205
|
+
Public scoped schema:
|
|
206
|
+
|
|
207
|
+
```json
|
|
208
|
+
{
|
|
209
|
+
"name": "SharedGeneration",
|
|
210
|
+
"type": "object",
|
|
211
|
+
"visibility": "public",
|
|
212
|
+
"properties": {
|
|
213
|
+
"shareId": {
|
|
214
|
+
"type": "string",
|
|
215
|
+
"autoGenerate": { "strategy": "uuid" }
|
|
216
|
+
},
|
|
217
|
+
"title": { "type": "string" },
|
|
218
|
+
"resultUrl": { "type": "string" },
|
|
219
|
+
"active": { "type": "boolean", "default": true },
|
|
220
|
+
"sourceGenerationId": { "type": "string" }
|
|
221
|
+
},
|
|
222
|
+
"required": ["shareId", "title", "resultUrl", "active", "sourceGenerationId"],
|
|
223
|
+
"access": {
|
|
224
|
+
"authenticated": { "read": "own", "create": "own", "update": "own", "delete": "own" },
|
|
225
|
+
"public": {
|
|
226
|
+
"read": "scoped",
|
|
227
|
+
"create": "none",
|
|
228
|
+
"update": "none",
|
|
229
|
+
"delete": "none",
|
|
230
|
+
"requiredScopes": ["shareId"],
|
|
231
|
+
"allowedFilters": ["shareId", "active"],
|
|
232
|
+
"allowedSorts": ["updatedDate"],
|
|
233
|
+
"defaultLimit": 1,
|
|
234
|
+
"maxLimit": 1
|
|
235
|
+
}
|
|
236
|
+
},
|
|
237
|
+
"indexes": [
|
|
238
|
+
{ "name": "share_id_unique", "fields": ["shareId"], "unique": true },
|
|
239
|
+
{ "name": "owner_updated", "fields": ["updatedDate"], "scope": "owner" }
|
|
240
|
+
]
|
|
241
|
+
}
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
Public page:
|
|
245
|
+
|
|
246
|
+
```ts
|
|
247
|
+
const result = await howone.public.entities.SharedGeneration.queryScoped({
|
|
248
|
+
where: { shareId, active: true },
|
|
249
|
+
page: { number: 1, size: 1 },
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
const shared = result.items[0] ?? null
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Rules:
|
|
256
|
+
|
|
257
|
+
- Do not expose private prompt fields unless the product explicitly wants that.
|
|
258
|
+
- Use `active` or a deletion flow for unshare.
|
|
259
|
+
- Keep public `maxLimit` at `1` for one-share pages.
|
|
260
|
+
|
|
261
|
+
## Retry Design
|
|
262
|
+
|
|
263
|
+
If the product has retry, store enough input fields to rebuild the workflow request.
|
|
264
|
+
|
|
265
|
+
Persist:
|
|
266
|
+
|
|
267
|
+
- user prompt or source content reference;
|
|
268
|
+
- selected mode/model/style options that affect output;
|
|
269
|
+
- uploaded file IDs/URLs needed by the workflow;
|
|
270
|
+
- status and failure message;
|
|
271
|
+
- timestamps.
|
|
272
|
+
|
|
273
|
+
Do not persist:
|
|
274
|
+
|
|
275
|
+
- temporary UI component state;
|
|
276
|
+
- auth/session/token values;
|
|
277
|
+
- raw streaming chunks;
|
|
278
|
+
- provider secrets;
|
|
279
|
+
- hidden prompt text that should stay server-side;
|
|
280
|
+
- large binary content when file upload should store a URL or file id.
|
|
281
|
+
|
|
282
|
+
Retry example:
|
|
283
|
+
|
|
284
|
+
```ts
|
|
285
|
+
const old = await howone.entities.Generation.getOrThrow(id)
|
|
286
|
+
|
|
287
|
+
const retry = await howone.entities.Generation.create({
|
|
288
|
+
prompt: old.prompt,
|
|
289
|
+
status: 'pending',
|
|
290
|
+
requestedAt: new Date().toISOString(),
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
const output = await howone.ai.generateImage.run({
|
|
294
|
+
prompt: old.prompt,
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
await howone.entities.Generation.update(retry.id, {
|
|
298
|
+
status: 'completed',
|
|
299
|
+
resultUrl: output.imageUrl,
|
|
300
|
+
completedAt: new Date().toISOString(),
|
|
301
|
+
})
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
Prefer creating a new retry record when the product is history-oriented. Prefer updating the same
|
|
305
|
+
record only when the product treats retry as replacing the original attempt.
|
|
306
|
+
|
|
307
|
+
## Resume Design
|
|
308
|
+
|
|
309
|
+
On page load, query persisted records instead of relying on local state:
|
|
310
|
+
|
|
311
|
+
```ts
|
|
312
|
+
const history = await howone.entities.Generation.query.mine({
|
|
313
|
+
where: { status: { in: ['pending', 'running', 'completed', 'failed'] } },
|
|
314
|
+
orderBy: { updatedDate: 'desc' },
|
|
315
|
+
page: { number: 1, size: 20 },
|
|
316
|
+
})
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Then:
|
|
320
|
+
|
|
321
|
+
- render `completed` from output fields;
|
|
322
|
+
- render `failed` from `errorMessage`;
|
|
323
|
+
- render `pending/running` as in progress only if the app has a way to poll status;
|
|
324
|
+
- mark stale pending records as failed/canceled when product rules define a timeout.
|
|
325
|
+
|
|
326
|
+
Do not leave indefinite pending rows with no recovery path.
|
|
327
|
+
|
|
328
|
+
## Field Mapping Checklist
|
|
329
|
+
|
|
330
|
+
Before implementing persistence, write the mapping explicitly:
|
|
331
|
+
|
|
332
|
+
```text
|
|
333
|
+
workflow input.prompt -> Generation.prompt
|
|
334
|
+
workflow input.style -> Generation.style
|
|
335
|
+
workflow output.imageUrl -> Generation.resultUrl
|
|
336
|
+
workflow output.caption -> Generation.resultText
|
|
337
|
+
workflow error.message -> Generation.errorMessage
|
|
338
|
+
runtime request started -> Generation.requestedAt
|
|
339
|
+
runtime request completed -> Generation.completedAt
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
If a workflow output field has no mapping, decide whether it is intentionally transient or whether
|
|
343
|
+
the entity schema is missing a product field.
|
|
344
|
+
|
|
345
|
+
## Access Checklist
|
|
346
|
+
|
|
347
|
+
Choose access based on product behavior:
|
|
348
|
+
|
|
349
|
+
| Product behavior | Entity access |
|
|
350
|
+
|---|---|
|
|
351
|
+
| User-only private generation history | authenticated own, public none |
|
|
352
|
+
| Team/shared authenticated library | authenticated all or future role-scoped model |
|
|
353
|
+
| Anonymous public gallery | public list with safe fields only |
|
|
354
|
+
| One public share link | public scoped with share id |
|
|
355
|
+
| Public submission to AI queue | public create only if abuse constraints are handled |
|
|
356
|
+
|
|
357
|
+
Never make the main generation history public just to support a public share page. Create a scoped
|
|
358
|
+
share entity or a curated public entity.
|
|
359
|
+
|
|
360
|
+
## Common Mistakes
|
|
361
|
+
|
|
362
|
+
| Mistake | Fix |
|
|
363
|
+
|---|---|
|
|
364
|
+
| Treating `outputSchema` as the entity schema | Design product persistence separately. |
|
|
365
|
+
| Saving raw workflow envelopes | Map only fields the product needs after refresh. |
|
|
366
|
+
| No status field | Add `status` and explicit failure fields. |
|
|
367
|
+
| Creating history only after success | Create pending first when history/resume matters. |
|
|
368
|
+
| Losing failures | Persist `failed` state and `errorMessage`. |
|
|
369
|
+
| Publicly exposing private prompt/history | Use a separate public scoped/share entity. |
|
|
370
|
+
| Retrying without stored inputs | Persist the inputs/options needed for retry. |
|
|
371
|
+
| Rendering from local state only | Reload from entity queries on page load. |
|
|
372
|
+
| Leaving stale pending forever | Add timeout/recovery behavior in product logic. |
|
|
@@ -22,19 +22,18 @@ type CreateClientOptions = {
|
|
|
22
22
|
caseStyle?: 'camel' | 'snake' // Default: 'camel'
|
|
23
23
|
mode?: 'auto' | 'standalone' | 'embedded'
|
|
24
24
|
|
|
25
|
-
// ── Auth
|
|
26
|
-
auth?: {
|
|
27
|
-
mode?: '
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
clearAllUrlParams?: boolean
|
|
35
|
-
sensitiveParams?: string[]
|
|
36
|
-
}
|
|
25
|
+
// ── Auth (one parameter for custom login) ─────────────────
|
|
26
|
+
auth?: 'custom' | 'hosted' | 'headless' | 'none' | {
|
|
27
|
+
mode?: 'custom' | 'hosted' | 'headless' | 'none' | 'managed'
|
|
28
|
+
loginPath?: string // default '/login' when mode is 'custom'
|
|
29
|
+
logoutPath?: string
|
|
30
|
+
guard?: 'required' | 'optional' | 'none'
|
|
31
|
+
getToken?: () => Promise<string | null>
|
|
32
|
+
adapter?: AuthAdapter
|
|
33
|
+
tokenCacheMs?: number
|
|
37
34
|
}
|
|
35
|
+
loginPath?: string // shorthand when auth is 'custom'
|
|
36
|
+
logoutPath?: string
|
|
38
37
|
|
|
39
38
|
// ── Limit-exceeded callbacks ───────────────────────────────
|
|
40
39
|
limitExceeded?: {
|
|
@@ -99,12 +98,16 @@ client.me(options?) // Promise<UserProfile | null>
|
|
|
99
98
|
client.requireMe(options?) // Promise<UserProfile> throws if unauthenticated
|
|
100
99
|
client.session.user() // alias for client.me()
|
|
101
100
|
|
|
102
|
-
// Auth helpers
|
|
101
|
+
// Auth helpers (behavior driven by createClient auth mode)
|
|
102
|
+
client.auth.mode // 'custom' | 'hosted' | 'headless' | 'none'
|
|
103
|
+
client.auth.loginPath // e.g. '/login'
|
|
103
104
|
client.auth.setToken(token: string | null)
|
|
104
105
|
client.auth.getToken(): string | null
|
|
105
106
|
client.auth.isAuthenticated(): boolean
|
|
106
|
-
client.auth.login(
|
|
107
|
-
client.auth.logout()
|
|
107
|
+
client.auth.login(returnUrl?: string)
|
|
108
|
+
await client.auth.logout()
|
|
109
|
+
await client.auth.clearSession({ redirect?: false | string })
|
|
110
|
+
client.auth.subscribe((state) => { ... }) // auth state callback
|
|
108
111
|
|
|
109
112
|
// URL utilities
|
|
110
113
|
client.sanitizeUrl(opts?: { clearAll?: boolean; sensitiveParams?: string[] })
|
|
@@ -121,6 +124,8 @@ import {
|
|
|
121
124
|
defineAiAction,
|
|
122
125
|
defineAiActions,
|
|
123
126
|
defineEntities,
|
|
127
|
+
pickEntityPayload,
|
|
128
|
+
runAiActionAndPersist,
|
|
124
129
|
type EntityRecord,
|
|
125
130
|
withAiActions,
|
|
126
131
|
withEntities,
|
|
@@ -159,6 +164,17 @@ const howone = withAiActions(withEntities(client, entities), ai)
|
|
|
159
164
|
export default howone
|
|
160
165
|
```
|
|
161
166
|
|
|
167
|
+
SDK utility exports that generated apps may use:
|
|
168
|
+
|
|
169
|
+
| Utility | Use |
|
|
170
|
+
|---|---|
|
|
171
|
+
| `pickEntityPayload(definition, payload)` | Keep only schema-declared business fields before create/update. |
|
|
172
|
+
| `validateEntityPayload(definition, payload)` | Return structured issues for unknown/system/ownership/missing required fields. |
|
|
173
|
+
| `assertEntityPayload(definition, payload)` | Throw structured `EntityPayloadValidationError` before unsafe writes. |
|
|
174
|
+
| `validatePublicEntityQuery(definition, options)` | Check public filters, sorts, scopes, and limits against `access.public`. |
|
|
175
|
+
| `assertPublicEntityQuery(definition, options)` | Throw before generating an invalid public query. |
|
|
176
|
+
| `runAiActionAndPersist(options)` | Standard pending-first AI execution + entity persistence helper. |
|
|
177
|
+
|
|
162
178
|
---
|
|
163
179
|
|
|
164
180
|
## Environment Variables
|
|
@@ -173,40 +189,46 @@ VITE_HOWONE_ENV=prod
|
|
|
173
189
|
Rules:
|
|
174
190
|
- **Do not** add `?? 'prod'` or `?? ''` fallbacks. Missing env vars should surface as misconfiguration errors.
|
|
175
191
|
- **Do not** hardcode project IDs in source. Use the env var.
|
|
176
|
-
- `env` accepts `'local'`, `'dev'`, or `'prod'`.
|
|
192
|
+
- `env` accepts `'local'`, `'dev'`, or `'prod'`. **Auth OTP/OAuth, entities, AI, and uploads all use this same env.**
|
|
193
|
+
- Import `src/lib/sdk.ts` before calling `loginWithEmailCode` / `unifiedAuth` so env is pinned (otherwise auth defaults to prod APIs).
|
|
194
|
+
|
|
195
|
+
| `env` | API base | Auth API example |
|
|
196
|
+
|-------|----------|------------------|
|
|
197
|
+
| `local` | `http://localhost:3002/api` | `http://localhost:3002/api/auth/email/send-code` |
|
|
198
|
+
| `dev` | `https://api.howone.dev/api` | `https://api.howone.dev/api/auth/email/send-code` |
|
|
199
|
+
| `prod` | `https://api.howone.ai/api` | `https://api.howone.ai/api/auth/email/send-code` |
|
|
177
200
|
|
|
178
201
|
---
|
|
179
202
|
|
|
180
203
|
## Auth Modes
|
|
181
204
|
|
|
205
|
+
See `03-sdk/03-auth.md` for the full custom-login playbook.
|
|
206
|
+
|
|
182
207
|
```ts
|
|
183
|
-
//
|
|
184
|
-
|
|
185
|
-
projectId: import.meta.env.VITE_HOWONE_PROJECT_ID,
|
|
186
|
-
env: import.meta.env.VITE_HOWONE_ENV,
|
|
187
|
-
auth: { mode: 'managed' },
|
|
188
|
-
})
|
|
208
|
+
// Default — HowOne hosted login (howone.dev / howone.ai)
|
|
209
|
+
createClient({ projectId, env })
|
|
189
210
|
|
|
190
|
-
//
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
211
|
+
// Custom in-app login page; auth APIs still HowOne
|
|
212
|
+
createClient({ projectId, env, auth: 'custom', loginPath: '/login' })
|
|
213
|
+
|
|
214
|
+
// Headless — external JWT provider
|
|
215
|
+
createClient({
|
|
216
|
+
projectId,
|
|
217
|
+
env,
|
|
194
218
|
auth: {
|
|
195
219
|
mode: 'headless',
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
220
|
+
adapter: {
|
|
221
|
+
getToken: async () => externalAuth.getToken(),
|
|
222
|
+
setToken: (token) => externalAuth.setToken(token),
|
|
223
|
+
login: ({ returnUrl } = {}) => router.push(`/login?redirect=${encodeURIComponent(returnUrl ?? '/')}`),
|
|
224
|
+
logout: () => router.push('/'),
|
|
199
225
|
},
|
|
200
|
-
tokenCacheMs: 60_000,
|
|
226
|
+
tokenCacheMs: 60_000,
|
|
201
227
|
},
|
|
202
228
|
})
|
|
203
229
|
|
|
204
|
-
// None —
|
|
205
|
-
|
|
206
|
-
projectId: import.meta.env.VITE_HOWONE_PROJECT_ID,
|
|
207
|
-
env: import.meta.env.VITE_HOWONE_ENV,
|
|
208
|
-
auth: { mode: 'none' },
|
|
209
|
-
})
|
|
230
|
+
// None — public app, no auth
|
|
231
|
+
createClient({ projectId, env, auth: 'none' })
|
|
210
232
|
```
|
|
211
233
|
|
|
212
234
|
---
|
|
@@ -260,7 +260,9 @@ const result = await howone.public.entities.Story.query({
|
|
|
260
260
|
```ts
|
|
261
261
|
type FieldOperator<T> = {
|
|
262
262
|
eq?: T // exact match (same as plain value)
|
|
263
|
+
equals?: T // alias for eq
|
|
263
264
|
ne?: T // not equal
|
|
265
|
+
not?: T // not equal alias
|
|
264
266
|
gt?: T // greater than
|
|
265
267
|
gte?: T // greater than or equal
|
|
266
268
|
lt?: T // less than
|
|
@@ -268,9 +270,14 @@ type FieldOperator<T> = {
|
|
|
268
270
|
contains?: string // substring (string fields)
|
|
269
271
|
like?: string // SQL LIKE pattern
|
|
270
272
|
startsWith?: string
|
|
273
|
+
starts?: string
|
|
271
274
|
endsWith?: string
|
|
275
|
+
ends?: string
|
|
272
276
|
in?: T[] // value in array
|
|
273
277
|
notIn?: T[] // value not in array
|
|
278
|
+
null?: boolean // null / not null
|
|
279
|
+
empty?: boolean // empty / not empty
|
|
280
|
+
exists?: boolean // field exists / missing
|
|
274
281
|
}
|
|
275
282
|
|
|
276
283
|
// Examples
|
|
@@ -346,6 +353,26 @@ Public query fields must be present in `access.public.allowedFilters`, and publi
|
|
|
346
353
|
fields must be present in `access.public.allowedSorts`. If a public query is rejected,
|
|
347
354
|
fix the schema access contract instead of falling back to authenticated APIs.
|
|
348
355
|
|
|
356
|
+
When generated code has the synced entity definition available, validate public queries before
|
|
357
|
+
calling the API:
|
|
358
|
+
|
|
359
|
+
```ts
|
|
360
|
+
import { assertPublicEntityQuery } from '@howone/sdk'
|
|
361
|
+
import { articleEntityDefinition } from '@/lib/sdk'
|
|
362
|
+
|
|
363
|
+
const query = {
|
|
364
|
+
where: { status: 'published' },
|
|
365
|
+
orderBy: { publishedAt: 'desc' },
|
|
366
|
+
page: { number: 1, size: 20 },
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
assertPublicEntityQuery(articleEntityDefinition, query)
|
|
370
|
+
const result = await howone.public.entities.Article.query(query)
|
|
371
|
+
```
|
|
372
|
+
|
|
373
|
+
Use `validatePublicEntityQuery()` when the app wants to show its own validation UI instead of
|
|
374
|
+
throwing.
|
|
375
|
+
|
|
349
376
|
---
|
|
350
377
|
|
|
351
378
|
## Public Writes
|
|
@@ -365,6 +392,44 @@ await howone.public.entities.ContactMessage.create({
|
|
|
365
392
|
|
|
366
393
|
---
|
|
367
394
|
|
|
395
|
+
## Payload Contract Utilities
|
|
396
|
+
|
|
397
|
+
Use these helpers when code maps form state, AI output, or mixed UI state into entity writes.
|
|
398
|
+
They prevent the common mistake of sending UI-only, workflow-envelope, ownership, or system fields.
|
|
399
|
+
|
|
400
|
+
```ts
|
|
401
|
+
import { pickEntityPayload, assertEntityPayload } from '@howone/sdk'
|
|
402
|
+
import { generationEntityDefinition } from '@/lib/sdk'
|
|
403
|
+
|
|
404
|
+
const draft = {
|
|
405
|
+
prompt,
|
|
406
|
+
status: 'pending',
|
|
407
|
+
created_by_id: user.id, // stripped/rejected
|
|
408
|
+
gradientDirection: 'to right', // stripped/rejected unless schema declares it
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const payload = pickEntityPayload(generationEntityDefinition, draft)
|
|
412
|
+
assertEntityPayload(generationEntityDefinition, payload)
|
|
413
|
+
|
|
414
|
+
await howone.entities.Generation.create(payload)
|
|
415
|
+
```
|
|
416
|
+
|
|
417
|
+
Rules:
|
|
418
|
+
|
|
419
|
+
- Use `pickEntityPayload()` when transforming broad UI objects into narrow create/update payloads.
|
|
420
|
+
- Use `validateEntityPayload()` to collect issues for app-owned validation UI.
|
|
421
|
+
- Use `assertEntityPayload()` before writes in generated helper functions.
|
|
422
|
+
- For updates, pass `{ partial: true }` to avoid requiring create-time fields.
|
|
423
|
+
- These helpers do not replace backend validation; they make generated frontend code fail earlier
|
|
424
|
+
with clearer errors.
|
|
425
|
+
|
|
426
|
+
```ts
|
|
427
|
+
assertEntityPayload(generationEntityDefinition, update, { partial: true })
|
|
428
|
+
await howone.entities.Generation.update(id, update)
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
---
|
|
432
|
+
|
|
368
433
|
## Bulk Create
|
|
369
434
|
|
|
370
435
|
```ts
|
|
@@ -474,3 +539,5 @@ function useDeleteStory() {
|
|
|
474
539
|
| `client.entity('Story')` without generics | `client.entity<StoryRecord, StoryCreate, StoryUpdate>('Story')` |
|
|
475
540
|
| Using `list()` when you need pagination | Use `query()` for paginated UIs |
|
|
476
541
|
| Calling `query()` inside render without guarding re-runs | Wrap in `useEffect` with cancellation or use TanStack Query |
|
|
542
|
+
| Sending form/workflow object directly to `create()` | Use `pickEntityPayload()` and `assertEntityPayload()` |
|
|
543
|
+
| Public query with illegal field/sort | Use `assertPublicEntityQuery()` and fix schema guardrails |
|