@weft-js/cli 0.1.1 → 0.1.2

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 (171) hide show
  1. package/README.md +38 -14
  2. package/bin/weft.mjs +8 -1
  3. package/docs/async.md +16 -6
  4. package/docs/browser.md +22 -8
  5. package/docs/config.md +39 -9
  6. package/docs/core.md +30 -0
  7. package/docs/deploy.md +33 -8
  8. package/docs/design.md +12 -3
  9. package/docs/entrypoints.md +79 -0
  10. package/docs/fonts.md +12 -2
  11. package/docs/icons.md +13 -7
  12. package/docs/lists.md +3 -1
  13. package/docs/meta.md +13 -3
  14. package/docs/plugins.md +32 -9
  15. package/docs/props.md +13 -2
  16. package/docs/reactivity.md +26 -0
  17. package/docs/rules.md +19 -12
  18. package/docs/rust.md +47 -17
  19. package/docs/start-short.md +3 -2
  20. package/docs/start.md +226 -52
  21. package/docs/state.md +33 -7
  22. package/docs/style.md +31 -8
  23. package/docs/surface.md +61 -15
  24. package/docs/testing.md +15 -5
  25. package/docs/theme.md +182 -0
  26. package/docs/ui-state.md +19 -6
  27. package/docs/verify.md +13 -0
  28. package/package.json +13 -4
  29. package/src/add/config.mjs +93 -0
  30. package/src/add/errors.mjs +2 -0
  31. package/src/add/setup.mjs +88 -0
  32. package/src/add-rust.mjs +82 -27
  33. package/src/add.mjs +209 -181
  34. package/src/agents-md.mjs +144 -43
  35. package/src/alias.mjs +16 -5
  36. package/src/audit/page.mjs +313 -0
  37. package/src/audit/report.mjs +69 -0
  38. package/src/audit/utils.mjs +5 -0
  39. package/src/audit.mjs +159 -206
  40. package/src/browser/cli.mjs +24 -5
  41. package/src/browser/client.mjs +189 -25
  42. package/src/browser/commands.mjs +166 -51
  43. package/src/catalog/audit.mjs +129 -0
  44. package/src/catalog/auth.mjs +40 -0
  45. package/src/catalog/compiler.mjs +337 -0
  46. package/src/catalog/contracts.mjs +18 -0
  47. package/src/catalog/database.mjs +52 -0
  48. package/src/catalog/deploy.mjs +117 -0
  49. package/src/catalog/design.mjs +159 -0
  50. package/src/catalog/forms.mjs +25 -0
  51. package/src/catalog/hydration.mjs +29 -0
  52. package/src/catalog/icons.mjs +24 -0
  53. package/src/catalog/media.mjs +163 -0
  54. package/src/catalog/metadata.mjs +116 -0
  55. package/src/catalog/plugins.mjs +228 -0
  56. package/src/catalog/realtime.mjs +195 -0
  57. package/src/catalog/rust.mjs +75 -0
  58. package/src/catalog/server-functions.mjs +92 -0
  59. package/src/catalog/state.mjs +39 -0
  60. package/src/catalog/style-groups.mjs +54 -0
  61. package/src/catalog/tests.mjs +127 -0
  62. package/src/catalog/text.mjs +200 -0
  63. package/src/catalog/ui-state.mjs +42 -0
  64. package/src/catalog.mjs +67 -460
  65. package/src/check/runtime.mjs +280 -0
  66. package/src/check.mjs +198 -208
  67. package/src/cli/authoring.mjs +213 -0
  68. package/src/cli/project.mjs +302 -0
  69. package/src/cli.mjs +222 -250
  70. package/src/config.mjs +1 -1
  71. package/src/contributions.mjs +75 -25
  72. package/src/db-layer.mjs +13 -5
  73. package/src/deploy/cdn.mjs +94 -16
  74. package/src/deploy/files.mjs +100 -21
  75. package/src/deploy/fit.mjs +127 -23
  76. package/src/deploy/hosts.mjs +90 -20
  77. package/src/deploy/index/cli.mjs +20 -0
  78. package/src/deploy/index/environment.mjs +69 -0
  79. package/src/deploy/index/steps.mjs +87 -0
  80. package/src/deploy/index.mjs +228 -114
  81. package/src/deploy/needs.mjs +88 -31
  82. package/src/design-checks/accessible-names.mjs +62 -0
  83. package/src/design-checks/contrast.mjs +43 -0
  84. package/src/design-checks/csp.mjs +60 -0
  85. package/src/design-checks/files.mjs +59 -0
  86. package/src/design-checks/heading-links.mjs +61 -0
  87. package/src/design-checks/interaction.mjs +65 -0
  88. package/src/design-checks/jsx.mjs +67 -0
  89. package/src/design-checks/layout-values.mjs +68 -0
  90. package/src/design-checks/native-controls.mjs +48 -0
  91. package/src/design-checks/outline.mjs +39 -0
  92. package/src/design-checks/reporter.mjs +37 -0
  93. package/src/design-checks/rules.mjs +274 -0
  94. package/src/design-checks/values.mjs +262 -0
  95. package/src/design-checks.mjs +10 -400
  96. package/src/diag.mjs +32 -8
  97. package/src/docs.mjs +138 -43
  98. package/src/doctor.mjs +20 -8
  99. package/src/files.mjs +18 -5
  100. package/src/fix.mjs +10 -2
  101. package/src/gate/after.mjs +40 -0
  102. package/src/gate/before.mjs +54 -0
  103. package/src/gate/classify.mjs +72 -0
  104. package/src/gate/mutation.mjs +209 -0
  105. package/src/gate/resolve-cases.mjs +22 -0
  106. package/src/gate/result.mjs +63 -0
  107. package/src/gate/run.mjs +75 -0
  108. package/src/gate.mjs +104 -202
  109. package/src/infer/box-props.cjs +140 -0
  110. package/src/infer/core-boxes.cjs +46 -0
  111. package/src/infer/derived-writes.cjs +95 -0
  112. package/src/infer/engine/checks.cjs +193 -0
  113. package/src/infer/engine/flow.cjs +293 -0
  114. package/src/infer/engine/report.cjs +158 -0
  115. package/src/infer/engine/utils.cjs +15 -0
  116. package/src/infer/engine.cjs +108 -368
  117. package/src/infer/facts/sites.cjs +114 -0
  118. package/src/infer/facts.cjs +210 -117
  119. package/src/infer/hosts.cjs +69 -22
  120. package/src/infer/lsp.mjs +149 -40
  121. package/src/infer/session.cjs +73 -17
  122. package/src/infer/shadow.cjs +13 -5
  123. package/src/infer/worker.cjs +57 -9
  124. package/src/jsx-scan.mjs +63 -21
  125. package/src/load-config/schema.mjs +281 -0
  126. package/src/load-config.mjs +137 -138
  127. package/src/map.mjs +93 -26
  128. package/src/mcp/browser-tools.mjs +155 -0
  129. package/src/mcp.mjs +147 -69
  130. package/src/meta-table.mjs +43 -8
  131. package/src/mutate.mjs +120 -36
  132. package/src/plugin-checks.mjs +52 -23
  133. package/src/plugins.mjs +125 -39
  134. package/src/project.mjs +170 -79
  135. package/src/props-checks.mjs +24 -6
  136. package/src/props-listener.mjs +27 -9
  137. package/src/react-checks.mjs +35 -4
  138. package/src/server-state-checks.mjs +34 -0
  139. package/src/share-checks.mjs +121 -37
  140. package/src/site.mjs +33 -6
  141. package/src/slots.mjs +104 -19
  142. package/src/start/build.mjs +116 -0
  143. package/src/start/routes.mjs +38 -0
  144. package/src/start.mjs +193 -95
  145. package/src/state-checks/core-state.mjs +223 -0
  146. package/src/state-checks/modules.mjs +17 -0
  147. package/src/state-checks/scan.mjs +232 -0
  148. package/src/state-checks/server-graph.mjs +65 -0
  149. package/src/state-checks.mjs +109 -133
  150. package/src/style/find.mjs +121 -0
  151. package/src/style.mjs +133 -127
  152. package/src/surface-checks.mjs +212 -46
  153. package/src/tailwind.mjs +5 -4
  154. package/src/tasks.mjs +189 -27
  155. package/src/test-rules/context.mjs +109 -0
  156. package/src/test-rules/file-rules.mjs +279 -0
  157. package/src/test-rules/rule-helpers.mjs +244 -0
  158. package/src/test-rules/syntax.mjs +223 -0
  159. package/src/test-rules/test-rules.mjs +127 -0
  160. package/src/testdiff.mjs +94 -32
  161. package/src/tests.mjs +31 -483
  162. package/src/text-checks.mjs +229 -69
  163. package/src/tokens.mjs +12 -4
  164. package/src/trust/scan.mjs +95 -0
  165. package/src/trust.mjs +214 -102
  166. package/src/weft.mjs +41 -9
  167. package/src/workspace.mjs +96 -19
  168. package/ts-plugin/context.js +57 -0
  169. package/ts-plugin/index.js +4 -124
  170. package/ts-plugin/mapping.js +65 -0
  171. package/ts-plugin/proxy.js +144 -0
package/docs/start.md CHANGED
@@ -5,11 +5,15 @@ Needs one of Node, Bun or Deno, and no Rust (`weft docs rust` is opt-in). An app
5
5
  ## Files
6
6
 
7
7
  ```
8
- src/routes/** file routes: _layout.tsx, index.tsx, [id].tsx, [...rest].tsx, 404.tsx, _error.tsx
9
- src/server/** server modules: queries and mutations (the client gets stubs); a Rust body: `weft docs rust`
8
+ src/routes/** file routes: _layout.tsx, index.tsx,
9
+ [id].tsx, [...rest].tsx, 404.tsx,
10
+ _error.tsx
11
+ src/server/** queries and mutations (client gets stubs);
12
+ a Rust body: `weft docs rust`
10
13
  src/db/schema.ts Drizzle tables; safe to import anywhere
11
14
  src/db/index.server.ts the database client
12
- src/auth.server.ts the auth provider (Better Auth by default), mounted at /api/auth/*
15
+ src/auth.server.ts auth provider (Better Auth by default),
16
+ mounted at /api/auth/*
13
17
  src/context.server.ts what `ctx` holds
14
18
  ```
15
19
 
@@ -24,9 +28,18 @@ Any auth provider works: `defineAuth` from `@weft-js/start/auth` takes a `handle
24
28
  import type { LoadArgs, Meta, RouteProps } from '@weft-js/start'
25
29
  import { getList } from '#/server/lists'
26
30
 
27
- export const load = async ({ params }: LoadArgs<'/lists/[id]'>) => ({ list: await getList.fetch({ id: params.id }) })
28
- export const meta: Meta<typeof load> = ({ data }) => ({ title: data.list.name })
29
- export default function List(props: RouteProps<typeof load, '/lists/[id]'>) { return <h1>{props.data.list.name}</h1> }
31
+ export const load = async ({ params }: LoadArgs<'/lists/[id]'>) => ({
32
+ list: await getList.fetch({ id: params.id }),
33
+ })
34
+ export const meta: Meta<typeof load> = ({ data }) => ({
35
+ title: data.list.name,
36
+ })
37
+ export default function List(
38
+ props: RouteProps<typeof load, '/lists/[id]'>,
39
+ ) {
40
+ const { data } = props
41
+ return <h1>{data.list.name}</h1>
42
+ }
30
43
  ```
31
44
 
32
45
  `props.data` and `const { data } = props` stay live; compute from them in JSX or `derived`, not in the component body (W001).
@@ -39,6 +52,30 @@ Set `meta` on every page as part of building it: a title and one plain sentence
39
52
  route build it from `data` and `params`. `weft docs meta` has the rest.
40
53
  Links are `<Link href>`; code navigates with `navigate(href)`.
41
54
 
55
+ Navigation is instant: the URL and `aria-current` change on click, kept layouts stay, and the level that changes shows a
56
+ skeleton made from its markup until its data arrives. Never build a loading state for a route change yourself. When the
57
+ made one doesn't fit (a page that is one big component, a hero), name a function of plain markup and export it:
58
+
59
+ ```tsx
60
+ function HomeSkeleton() {
61
+ return (
62
+ <section data-site="hero">
63
+ <h1>Your headline</h1>
64
+ <p data-skeleton></p>
65
+ </section>
66
+ )
67
+ }
68
+ export const skeleton = HomeSkeleton
69
+ ```
70
+
71
+ It runs before the page's code loads, so only `div span section article main header footer p h1-h6 ul ol li`, string
72
+ `class`/`data-*` attributes and text; anything else warns and leaves no skeleton. `export const skeleton = false` keeps
73
+ the old page up instead.
74
+
75
+ Chrome shared by several sections (a sidebar, section tabs) goes in the one layout above them, following the URL with
76
+ `currentPath()` from `@weft-js/start`. A `_layout.tsx` per section is replaced, chrome and all, whenever you move
77
+ between sections, so the sidebar flashes and is rebuilt. Keep `{props.children}` in the same parent on every page.
78
+
42
79
  ## Server functions
43
80
 
44
81
  ```ts
@@ -50,21 +87,42 @@ import { z } from 'zod'
50
87
  import * as schema from '#/db/schema'
51
88
 
52
89
  const { todos } = schema
53
- const schemas = schemasOf(schema, { todos: { title: (s: z.ZodString) => s.trim().min(1) } })
90
+ const schemas = schemasOf(schema, {
91
+ todos: { title: (s: z.ZodString) => s.trim().min(1) },
92
+ })
54
93
 
55
94
  export const listTodos = query({
56
95
  tags: ['todos'], // what invalidates it
57
- auth: 'user', // anyone else gets A002; ctx.user is non-null here
58
- run: ({ ctx }) => ctx.db.select().from(todos).where(eq(todos.userId, ctx.user.id)),
96
+ auth: 'user', // anyone else gets A002; ctx.user is non-null
97
+ run: ({ ctx }) => ctx.db
98
+ .select()
99
+ .from(todos)
100
+ .where(eq(todos.userId, ctx.user.id)),
59
101
  })
60
102
 
61
103
  export const addTodo = mutation({
62
104
  input: schemas.todos.insert.pick({ title: true }),
63
105
  auth: 'user',
64
- invalidates: ['todos'], // queries with this tag come back in the same response
65
- optimistic: ({ input }, cache) => cache.update(listTodos, undefined, l => void l.push({ id: `tmp:${input.title}`, title: input.title, done: false, userId: '', createdAt: 0 })),
106
+ invalidates: ['todos'], // tagged queries return in the response
107
+ optimistic: ({ input }, cache) => {
108
+ return cache.update(listTodos, undefined, l => void l.push({
109
+ id: `tmp:${input.title}`,
110
+ title: input.title,
111
+ done: false,
112
+ userId: '',
113
+ createdAt: 0,
114
+ }))
115
+ },
66
116
  run: async ({ input, ctx }) => {
67
- const [row] = await ctx.db.insert(todos).values({ id: crypto.randomUUID(), userId: ctx.user.id, createdAt: Date.now(), ...input }).returning()
117
+ const [row] = await ctx.db
118
+ .insert(todos)
119
+ .values({
120
+ id: crypto.randomUUID(),
121
+ userId: ctx.user.id,
122
+ createdAt: Date.now(),
123
+ ...input,
124
+ })
125
+ .returning()
68
126
  return row
69
127
  },
70
128
  })
@@ -84,6 +142,18 @@ A table a function touches must be in its `tags`/`invalidates` (Q011): a tag nam
84
142
  **`auth: 'user'` says who is calling, not what they may touch.** A `run` taking a row's id must check that row belongs
85
143
  to `ctx.user`. `and(eq(todos.id, input.id), eq(todos.userId, ctx.user.id))` in the `where` is the usual way.
86
144
 
145
+ ## Form fields
146
+
147
+ `form.field(name)` restores the raw submitted value, which can
148
+ be a string or an array for repeated names. Pass `'text'` or
149
+ `'email'` as the second argument for a string-valued input:
150
+ `form.field('email', 'email')`. If a name was repeated, these
151
+ modes restore its first value. Pass `'multiple'` to restore an
152
+ array, including when only one value was submitted. Missing
153
+ values stay absent. Use the raw default for checkbox and radio
154
+ fields when their submitted values need no projection.
155
+ `form.value(name)` always reads the raw restored value.
156
+
87
157
  ## Database
88
158
 
89
159
  Drizzle, async on every driver: `await ctx.db.select()…`, `const [row] = await ….returning()`,
@@ -111,8 +181,13 @@ import auth from '#/auth.server'
111
181
  export const signIn = mutation({
112
182
  input: z.object({ email: z.email(), password: z.string().min(8) }),
113
183
  run: async ({ input, ctx }) => {
114
- await forward(ctx, auth.api.signInEmail({ body: input, headers: ctx.request.headers, asResponse: true }))
115
- redirect('/todos') // with a new session, a full load: nothing cached survives (A004 without one)
184
+ await forward(ctx, auth.api.signInEmail({
185
+ body: input,
186
+ headers: ctx.request.headers,
187
+ asResponse: true,
188
+ }))
189
+ // A new session needs a full load; cached data won't survive.
190
+ redirect('/todos') // A004 without a full load
116
191
  },
117
192
  })
118
193
  ```
@@ -122,6 +197,57 @@ A guard: `export const load = ({ ctx }: LoadArgs) => ctx.user ?? redirect('/logi
122
197
  `BETTER_AUTH_URL` in `.env` must match where the app runs: on another port (`weft dev --port`), sign-in answers
123
198
  `INVALID_ORIGIN`.
124
199
 
200
+ **Login is an opaque token in the database, not a JWT.** The HTTP-only cookie holds a random token, so a session can be
201
+ revoked by deleting its row, and nothing about it can outlive that. A JWT can't be taken back before it expires.
202
+
203
+ **The session cache.** `createAuth` turns on Better Auth's cookie cache: an HTTP-only `session_data` cookie (`compact`:
204
+ signed, not encrypted) that spares a page and a query their two
205
+ session reads for **5 minutes**. So a revoked session can still be shown pages for up to 5 minutes. It can't write: a
206
+ mutation, a realtime connection and Better Auth's own routes always read the database, without you asking, and
207
+ sign-out and a password change clear the cookie. A request past the window reads the database and renews it.
208
+
209
+ ```ts
210
+ // A shorter window
211
+ createAuth({
212
+ db, secret,
213
+ options: { database, session: { cookieCache: { maxAge: 60 } } },
214
+ })
215
+ // Every request reads the database
216
+ createAuth({
217
+ db, secret,
218
+ options: {
219
+ database, session: { cookieCache: { enabled: false } },
220
+ },
221
+ })
222
+ ```
223
+
224
+ A hand-written provider follows the same rule: its `session` may cache for GET, never for another method.
225
+
226
+ **API access.** JWTs are for other services and API clients only, and never log a browser in. `GET /api/auth/token`, with a
227
+ live session, gives a token good for 15 minutes; `GET /api/auth/jwks` is public, so a service verifies it with the public
228
+ keys and no database (`iss` and `aud` are `BETTER_AUTH_URL`). Signing keys live in the `jwks` table of the auth schema. A
229
+ function takes a token only if it's listed, and `auth: 'user'` still applies:
230
+
231
+ ```ts
232
+ createAuth({
233
+ db, secret, options,
234
+ api: {
235
+ functions: () => [orders, shipOrder],
236
+ jwt: {
237
+ audience: 'https://api.example.com', expirationTime: '15m',
238
+ },
239
+ },
240
+ })
241
+ ```
242
+
243
+ GET /_weft/fn/<id>?i=<json> authorization: Bearer <token>
244
+ POST /_weft/fn/<id> authorization: Bearer <token>, content-type: application/json, x-weft: 1, body {"i": input}
245
+
246
+ `<id>` is `fnOf(orders).$id`. `ctx.user` is what the token says and `ctx.session` is `null`. An unlisted function ignores the
247
+ token. A token can't be revoked, so reads last until it expires; a mutation also checks that its session still exists.
248
+ With no `BETTER_AUTH_URL` or fixed `baseURL`, no token is accepted, since nothing could check `iss` and `aud`.
249
+ `api: false` removes all of this.
250
+
125
251
  ## Runtimes
126
252
 
127
253
  The CLI runs on whichever of the three starts it, with none of the others installed: `bun --bun`, or `deno run -A
@@ -147,8 +273,10 @@ clean up for you.
147
273
  on any tab, refetches it for every reader. Unchanged rows keep their identity, so the page writes what changed.
148
274
 
149
275
  ```tsx
150
- const todos = listTodos.live() // the same Resource as listTodos(), plus todos.live.value
151
- <p>{todos.live.value}</p> // 'connecting' | 'live' | 'degraded' | 'reconnecting' | 'offline'
276
+ // Same Resource as listTodos(), plus todos.live.value
277
+ const todos = listTodos.live()
278
+ // 'connecting' | 'live' | 'degraded' | 'reconnecting' | 'offline'
279
+ <p>{todos.live.value}</p>
152
280
  ```
153
281
 
154
282
  A live query needs `tags` (W053), and its mutations need one of them in `invalidates` (R003 in dev).
@@ -162,22 +290,31 @@ import { z } from 'zod'
162
290
 
163
291
  const Price = z.object({ symbol: z.string(), price: z.number() })
164
292
 
165
- export const priceTopic = topic('prices', { key: z.string().max(12), message: Price })
293
+ export const priceTopic = topic('prices', {
294
+ key: z.string().max(12), message: Price,
295
+ })
166
296
  export const prices = stream({
167
- input: z.object({ symbols: z.array(z.string().max(12)).max(50) }), // bound it (R005)
297
+ // Bound the input (R005).
298
+ input: z.object({ symbols: z.array(z.string().max(12)).max(50) }),
168
299
  message: Price,
169
- auth: 'user', // optional; re-checked every 5 minutes
300
+ auth: 'user', // optional; re-checked every 5 minutes
170
301
  async *run({ input, signal, lastEventId }) {
171
- yield* priceTopic.subscribe(input.symbols, { signal, after: lastEventId }) // subscribe first (R006)
302
+ // Subscribe first (R006).
303
+ yield* priceTopic.subscribe(input.symbols, {
304
+ signal, after: lastEventId,
305
+ })
172
306
  },
173
307
  })
174
- // anywhere on the server: await priceTopic.publish('AAPL', { symbol: 'AAPL', price: 1 })
308
+ // Anywhere on the server:
309
+ // await priceTopic.publish('AAPL', { symbol: 'AAPL', price: 1 })
175
310
  ```
176
311
 
177
312
  ```tsx
178
- const tick = prices({ symbols: ['AAPL'] }) // tick.value: the latest message
179
- const board = prices(() => ({ symbols: syms.value }), { // input is tracked; `skip` works
180
- fold: (acc, m) => ({ ...acc, [m.symbol]: m }), initial: {}, keep: '30s',
313
+ const tick = prices({ symbols: ['AAPL'] }) // latest: tick.value
314
+ // Input is tracked; `skip` works.
315
+ const board = prices(() => ({ symbols: syms.value }), {
316
+ fold: (acc, m) => ({ ...acc, [m.symbol]: m }),
317
+ initial: {}, keep: '30s',
181
318
  })
182
319
  ```
183
320
 
@@ -193,7 +330,11 @@ undefined after a reset: start again, or read from the database.
193
330
  import { realtime } from '@weft-js/start'
194
331
  import { postgresBus } from '@weft-js/start/bus/postgres'
195
332
 
196
- export default realtime({ bus: postgresBus({ url: process.env.DATABASE_URL! }), origins: ['https://other.example'], maxDuration: 300 })
333
+ export default realtime({
334
+ bus: postgresBus({ url: process.env.DATABASE_URL! }),
335
+ origins: ['https://other.example'],
336
+ maxDuration: 300,
337
+ })
197
338
  ```
198
339
 
199
340
  `postgresBus` needs a direct connection: pgBouncer in transaction mode breaks LISTEN. `maxDuration` is the platform's
@@ -234,10 +375,13 @@ import { eq } from 'drizzle-orm'
234
375
  import { notes } from '#/db/schema'
235
376
 
236
377
  export const myNotes = query({
237
- tags: ['notes'], // table name, or `notes:${id}` for one row
378
+ tags: ['notes'], // table name, or `notes:${id}` for one row
238
379
  auth: 'user',
239
380
  live: { required: true, verify: 'staging' }, // both optional
240
- run: ({ ctx }) => ctx.db.select().from(notes).where(eq(notes.owner, ctx.user.id)),
381
+ run: ({ ctx }) => ctx.db
382
+ .select()
383
+ .from(notes)
384
+ .where(eq(notes.owner, ctx.user.id)),
241
385
  })
242
386
  ```
243
387
 
@@ -249,7 +393,8 @@ A notification carries a table and primary keys, never a column. Each client ref
249
393
 
250
394
  ```tsx
251
395
  const notes = myNotes.live()
252
- notes.live.value // … | 'degraded': connected, and outside writes arrive late or not at all
396
+ // 'degraded': connected; outside writes arrive late or not at all
397
+ notes.live.value
253
398
  notes.liveMode.value // 'feed' | 'external' | 'poll' | 'app' | 'off'
254
399
  ```
255
400
 
@@ -275,9 +420,15 @@ Polling here is the framework's watcher, in one place on the server. R007 and T0
275
420
  <!-- example: skip two files in one block; needs a notes table -->
276
421
  ```ts
277
422
  // listener.ts: on a VPS, Fly or Railway. `weft db listen` runs it.
278
- export default realtime({ bus: postgresBus({ url: env.DATABASE_URL }), changes: dbChanges(db, [notes]) })
423
+ export default realtime({
424
+ bus: postgresBus({ url: env.DATABASE_URL }),
425
+ changes: dbChanges(db, [notes]),
426
+ })
279
427
  // the app, on Vercel
280
- export default realtime({ bus: postgresBus({ url: env.DATABASE_URL }), changes: dbChanges(db, [notes], { listener: 'external' }) })
428
+ export default realtime({
429
+ bus: postgresBus({ url: env.DATABASE_URL }),
430
+ changes: dbChanges(db, [notes], { listener: 'external' }),
431
+ })
281
432
  ```
282
433
 
283
434
  `weft db listen` loads your server modules from source, so it runs from a checkout with dependencies installed, and with the same env as the app. A listener that stops is noticed: its heartbeat ends, and within a minute `.live()` shows `reconnecting`.
@@ -287,17 +438,25 @@ export default realtime({ bus: postgresBus({ url: env.DATABASE_URL }), changes:
287
438
  **`verify`** proves the chain end to end on every `weft check` and `weft build`, cached by query and contract:
288
439
 
289
440
  ```ts
290
- live: { required: true, verify: 'staging' } // a name in weft.config.ts
291
- live: { verify: { url: 'https://staging.example.dev' } } // a running app
292
- live: { verify: { db: process.env.STAGING_DATABASE_URL } } // a database, as an outside writer
293
- live: { verify: true } // finds WEFT_STAGING_URL, STAGING_DATABASE_URL
441
+ live: { required: true, verify: 'staging' } // name in config
442
+ live: { verify: { url: 'https://staging.example.dev' } } // app
443
+ // A database, as an outside writer
444
+ live: { verify: { db: process.env.STAGING_DATABASE_URL } }
445
+ // Finds WEFT_STAGING_URL or STAGING_DATABASE_URL
446
+ live: { verify: true }
294
447
  ```
295
448
 
296
449
  Setup, once:
297
450
 
298
451
  ```ts
299
452
  // weft.config.ts
300
- live: { targets: { staging: { url: 'https://staging.example.dev', db: 'STAGING_DATABASE_URL', production: false } } }
453
+ live: {
454
+ targets: { staging: {
455
+ url: 'https://staging.example.dev',
456
+ db: 'STAGING_DATABASE_URL',
457
+ production: false,
458
+ } },
459
+ }
301
460
  ```
302
461
 
303
462
  Set `STAGING_DATABASE_URL`, and `WEFT_STAGING_COOKIE` and `WEFT_STAGING_OTHER_COOKIE` for a signed-in user and one who shouldn't see the row. If the query's table has a required column, say what to write: `verify: { target: 'staging', row: { owner: 'alice' } }`.
@@ -321,26 +480,35 @@ import { isMember, insertMessage, Message } from '#/server/messages'
321
480
 
322
481
  export const room = channel({
323
482
  params: z.object({ roomId: z.string().max(64) }),
324
- authorize: ({ ctx, params }) => isMember(ctx.user, params.roomId), // false: status 'unauthorized' (R011)
325
- client: { say: z.object({ text: z.string().min(1).max(2000) }) }, // always validated; bound them (R005)
326
- server: { said: Message }, // validated in dev and tests
483
+ // False gives status 'unauthorized' (R011).
484
+ authorize: ({ ctx, params }) => isMember(ctx.user, params.roomId),
485
+ // Always validated; bound messages (R005).
486
+ client: { say: z.object({ text: z.string().min(1).max(2000) }) },
487
+ server: { said: Message }, // validated in dev and tests
327
488
  on: {
328
489
  say: async ({ msg, ctx, params, room }) => {
329
- const row = await insertMessage(ctx, params.roomId, msg.text) // throw StartError or FnError to refuse `send`
330
- await room.publish('said', row) // `{ exceptSelf: true }` skips the sender
490
+ // Throw StartError or FnError to refuse `send`.
491
+ const row = await insertMessage(ctx, params.roomId, msg.text)
492
+ // { exceptSelf: true } skips the sender.
493
+ await room.publish('said', row)
331
494
  },
332
495
  },
333
- limits: { perSecond: 10 }, // default 20 a second per socket
496
+ limits: { perSecond: 10 }, // default 20 a second per socket
334
497
  })
335
- // from a mutation or a job: await room.in({ roomId }).publish('said', row)
498
+ // From a mutation or a job:
499
+ // await room.in({ roomId }).publish('said', row)
336
500
  ```
337
501
 
338
502
  ```tsx
339
- const chat = room.connect(() => ({ roomId: props.roomId })) // tracked; `skip` leaves; { keep: '30s' }
340
- const said = chat.collect('said', { max: 200, key: m => m.id }) // Derived<Message[]>, kept in the cache
341
- chat.on('typing', ({ userId }) => show(userId)) // removed with the owner
342
- await chat.send('say', { text }) // resolves on the server's ack; rejects with the refusal
343
- chat.status.value // 'connecting' | 'open' | 'reconnecting' | 'closed' | 'unauthorized'
503
+ // Tracked; `skip` leaves; { keep: '30s' }
504
+ const chat = room.connect(() => ({ roomId: props.roomId }))
505
+ // Derived<Message[]>, kept in the cache
506
+ const said = chat.collect('said', { max: 200, key: m => m.id })
507
+ chat.on('typing', ({ userId }) => show(userId)) // owner removes it
508
+ // Resolves on the server's ack; rejects with the refusal
509
+ await chat.send('say', { text })
510
+ // 'connecting' | 'open' | 'reconnecting' | 'closed' | 'unauthorized'
511
+ chat.status.value
344
512
  ```
345
513
 
346
514
  **The channel is a wire, not a log.** A reconnect replays what the last 60 s or 256 messages hold, per instance. Past
@@ -355,7 +523,9 @@ import { invalidate } from '@weft-js/start'
355
523
  const history = listMessages({ roomId })
356
524
  const said = chat.collect('said', { key: m => m.id })
357
525
  chat.onReset(() => invalidate('messages'))
358
- const all = derived(() => dedupe([...(history.value ?? []), ...said.value]))
526
+ const all = derived(() =>
527
+ dedupe([...(history.value ?? []), ...said.value]),
528
+ )
359
529
  ```
360
530
 
361
531
  A refused join is only known once the socket is up, and `status` is `'connecting'` on the server. To render "not
@@ -383,11 +553,15 @@ import { sharedState, skip } from '@weft-js/start'
383
553
  import { z } from 'zod'
384
554
 
385
555
  export const deckSlide = sharedState('deck-slide', {
386
- key: z.string().max(64), value: z.number().int().max(999), initial: 0,
387
- authorize: { read: isParticipant, write: isPresenter }, // ({ ctx, key }) => boolean, both required
556
+ key: z.string().max(64),
557
+ value: z.number().int().max(999),
558
+ initial: 0,
559
+ // Both ({ ctx, key }) => boolean functions are required.
560
+ authorize: { read: isParticipant, write: isPresenter },
388
561
  })
389
- await deckSlide.in(id).set(3) // on the server
390
- const slide = deckSlide.connect(() => (id.value ? id.value : skip)) // in a defineState: State<number> & { status, error }
562
+ await deckSlide.in(id).set(3) // on the server
563
+ // In defineState: State<number> & { status, error }
564
+ const slide = deckSlide.connect(() => (id.value ? id.value : skip))
391
565
  ```
392
566
 
393
567
  Its value lives in server memory: it survives a restart only while another instance runs. What must last is a mutation.
package/docs/state.md CHANGED
@@ -3,14 +3,24 @@
3
3
  Every reactive function in one place. The rules for using them are `weft docs rules`; props are `weft docs props`.
4
4
 
5
5
  ```tsx
6
- import { derived, flush, onCleanup, onMount, state, watch } from '@weft-js/core'
6
+ import {
7
+ derived,
8
+ flush,
9
+ onCleanup,
10
+ onMount,
11
+ state,
12
+ watch,
13
+ } from '@weft-js/core'
7
14
 
8
15
  export function Counter() {
9
16
  const count = state(0)
10
17
  const label = derived(() => `Clicked ${count.value} times`)
11
18
  let input: HTMLInputElement | undefined
12
19
 
13
- watch(() => label.value, text => { document.title = text; return () => (document.title = 'App') })
20
+ watch(() => label.value, text => {
21
+ document.title = text
22
+ return () => (document.title = 'App')
23
+ })
14
24
  onMount(() => input?.focus())
15
25
  onCleanup(() => console.log('Counter removed'))
16
26
 
@@ -21,8 +31,13 @@ export function Counter() {
21
31
  }
22
32
  return (
23
33
  <p>
24
- <button type="button" onClick={() => count.value++}>{label.value}</button>
25
- <input ref={(n: HTMLInputElement) => (input = n)} aria-label="Note" />
34
+ <button type="button" onClick={() => count.value++}>
35
+ {label.value}
36
+ </button>
37
+ <input
38
+ ref={(n: HTMLInputElement) => (input = n)}
39
+ aria-label="Note"
40
+ />
26
41
  <button type="button" onClick={reset}>Reset</button>
27
42
  </p>
28
43
  )
@@ -66,9 +81,20 @@ import { defineState, derived, state } from '@weft-js/core'
66
81
  import { z } from 'zod'
67
82
 
68
83
  export const cart = defineState('cart', () => {
69
- const lines = state<Record<string, number>>({}, { persist: { in: 'local', schema: z.record(z.string(), z.number()), version: 1 }, sync: 'tabs' })
70
- const count = derived(() => Object.values(lines.value).reduce((a, b) => a + b, 0))
71
- const add = (id: string) => lines.set(d => { d[id] = (d[id] ?? 0) + 1 })
84
+ const lines = state<Record<string, number>>({}, {
85
+ persist: {
86
+ in: 'local',
87
+ schema: z.record(z.string(), z.number()),
88
+ version: 1,
89
+ },
90
+ sync: 'tabs',
91
+ })
92
+ const count = derived(() =>
93
+ Object.values(lines.value).reduce((a, b) => a + b, 0),
94
+ )
95
+ const add = (id: string) => lines.set(d => {
96
+ d[id] = (d[id] ?? 0) + 1
97
+ })
72
98
  return { lines, count, add }
73
99
  }, { history: { limit: 100 } })
74
100
  ```
package/docs/style.md CHANGED
@@ -9,20 +9,43 @@ import { group } from '@weft-js/core/style'
9
9
 
10
10
  /** A summary card for one order in a list. */
11
11
  export const orderCard = group('order-card', {
12
- base: { surface: 'raised', pad: 'group', radius: 'surface', layout: 'stack', gap: 'related' },
13
- axes: { status: { open: { tone: 'info' }, shipped: { tone: 'positive' }, late: { tone: 'critical' } } },
14
- parts: { title: { text: 'title' }, meta: { text: 'caption', ink: 'subtle' } },
12
+ base: {
13
+ surface: 'raised',
14
+ pad: 'group',
15
+ radius: 'surface',
16
+ layout: 'stack',
17
+ gap: 'related',
18
+ },
19
+ axes: {
20
+ status: {
21
+ open: { tone: 'info' },
22
+ shipped: { tone: 'positive' },
23
+ late: { tone: 'critical' },
24
+ },
25
+ },
26
+ parts: {
27
+ title: { text: 'title' },
28
+ meta: { text: 'caption', ink: 'subtle' },
29
+ },
15
30
  states: { selected: { ring: 'accent' } },
16
31
  defaults: { status: 'open' },
17
32
  })
18
33
 
19
- type Order = { name: string; date: string; status: 'open' | 'shipped' | 'late' }
34
+ type Order = {
35
+ name: string
36
+ date: string
37
+ status: 'open' | 'shipped' | 'late'
38
+ }
20
39
 
21
- export function OrderCard(props: { order: Order; selected: boolean }) {
40
+ export function OrderCard(props: {
41
+ order: Order
42
+ selected: boolean
43
+ }) {
44
+ const { order, selected } = props
22
45
  return (
23
- <article {...orderCard({ status: props.order.status, selected: props.selected })}>
24
- <h3 {...orderCard.title}>{props.order.name}</h3>
25
- <p {...orderCard.meta}>{props.order.date}</p>
46
+ <article {...orderCard({ status: order.status, selected })}>
47
+ <h3 {...orderCard.title}>{order.name}</h3>
48
+ <p {...orderCard.meta}>{order.date}</p>
26
49
  </article>
27
50
  )
28
51
  }