howone 0.1.22 → 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.
Files changed (26) hide show
  1. package/package.json +1 -1
  2. package/templates/nextjs/lib/sdk.ts +3 -0
  3. package/templates/vite/.howone/skills/howone-sdk/01-architect/01-app-generation.md +183 -69
  4. package/templates/vite/.howone/skills/howone-sdk/01-architect/02-manifest-codegen.md +98 -23
  5. package/templates/vite/.howone/skills/howone-sdk/02-database/01-schema-design.md +463 -69
  6. package/templates/vite/.howone/skills/howone-sdk/02-database/02-schema-operations.md +366 -64
  7. package/templates/vite/.howone/skills/howone-sdk/02-database/03-data-access-patterns.md +204 -67
  8. package/templates/vite/.howone/skills/howone-sdk/02-database/04-query-dsl-and-responses.md +237 -0
  9. package/templates/vite/.howone/skills/howone-sdk/02-database/05-ai-persistence-patterns.md +372 -0
  10. package/templates/vite/.howone/skills/howone-sdk/03-sdk/01-client-setup.md +58 -36
  11. package/templates/vite/.howone/skills/howone-sdk/03-sdk/02-entity-operations.md +67 -0
  12. package/templates/vite/.howone/skills/howone-sdk/03-sdk/03-auth.md +267 -469
  13. package/templates/vite/.howone/skills/howone-sdk/03-sdk/04-react-integration.md +113 -322
  14. package/templates/vite/.howone/skills/howone-sdk/03-sdk/07-ai-action-calls.md +95 -48
  15. package/templates/vite/.howone/skills/howone-sdk/03-sdk/08-extension-boundaries.md +226 -0
  16. package/templates/vite/.howone/skills/howone-sdk/04-ai/01-ai-capability-architecture.md +205 -0
  17. package/templates/vite/.howone/skills/howone-sdk/04-ai/02-workflow-contract-rules.md +426 -0
  18. package/templates/vite/.howone/skills/howone-sdk/04-ai/03-ai-sdk-handoff.md +219 -0
  19. package/templates/vite/.howone/skills/howone-sdk/04-ai/04-service-capability-catalog.md +281 -0
  20. package/templates/vite/.howone/skills/howone-sdk/04-ai/05-workflow-operations.md +256 -0
  21. package/templates/vite/.howone/skills/howone-sdk/04-ai/06-ai-feature-playbooks.md +296 -0
  22. package/templates/vite/.howone/skills/howone-sdk/SKILL.md +83 -15
  23. package/templates/vite/.howone/skills/howone-sdk/agents/openai.yaml +2 -2
  24. package/templates/vite/package.json +1 -1
  25. package/templates/vite/src/lib/sdk.ts +3 -0
  26. 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?: 'none' | 'managed' | 'headless'
28
- getToken?: () => Promise<string | null> // Custom token provider (headless)
29
- tokenCacheMs?: number // How long to cache the token
30
- tokenInjection?: {
31
- allowedOrigins?: string[]
32
- waitMs?: number
33
- clearUrlParamsAfterInjectionMs?: number
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(redirect?: string) // redirects to HowOne login page
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'`. SDK routes API calls to the correct endpoint automatically.
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
- // Managed (default) SDK handles login redirect and token storage
184
- const client = createClient({
185
- projectId: import.meta.env.VITE_HOWONE_PROJECT_ID,
186
- env: import.meta.env.VITE_HOWONE_ENV,
187
- auth: { mode: 'managed' },
188
- })
208
+ // DefaultHowOne hosted login (howone.dev / howone.ai)
209
+ createClient({ projectId, env })
189
210
 
190
- // Headless provide your own token (e.g. Supabase, Clerk, etc.)
191
- const client = createClient({
192
- projectId: import.meta.env.VITE_HOWONE_PROJECT_ID,
193
- env: import.meta.env.VITE_HOWONE_ENV,
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
- getToken: async () => {
197
- // return your JWT or null
198
- return localStorage.getItem('my_token')
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, // cache for 1 minute
226
+ tokenCacheMs: 60_000,
201
227
  },
202
228
  })
203
229
 
204
- // None — no auth, all requests are unauthenticated
205
- const client = createClient({
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 |