@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.
- package/README.md +38 -14
- package/bin/weft.mjs +8 -1
- package/docs/async.md +16 -6
- package/docs/browser.md +22 -8
- package/docs/config.md +39 -9
- package/docs/core.md +30 -0
- package/docs/deploy.md +33 -8
- package/docs/design.md +12 -3
- package/docs/entrypoints.md +79 -0
- package/docs/fonts.md +12 -2
- package/docs/icons.md +13 -7
- package/docs/lists.md +3 -1
- package/docs/meta.md +13 -3
- package/docs/plugins.md +32 -9
- package/docs/props.md +13 -2
- package/docs/reactivity.md +26 -0
- package/docs/rules.md +19 -12
- package/docs/rust.md +47 -17
- package/docs/start-short.md +3 -2
- package/docs/start.md +226 -52
- package/docs/state.md +33 -7
- package/docs/style.md +31 -8
- package/docs/surface.md +61 -15
- package/docs/testing.md +15 -5
- package/docs/theme.md +182 -0
- package/docs/ui-state.md +19 -6
- package/docs/verify.md +13 -0
- package/package.json +13 -4
- package/src/add/config.mjs +93 -0
- package/src/add/errors.mjs +2 -0
- package/src/add/setup.mjs +88 -0
- package/src/add-rust.mjs +82 -27
- package/src/add.mjs +209 -181
- package/src/agents-md.mjs +144 -43
- package/src/alias.mjs +16 -5
- package/src/audit/page.mjs +313 -0
- package/src/audit/report.mjs +69 -0
- package/src/audit/utils.mjs +5 -0
- package/src/audit.mjs +159 -206
- package/src/browser/cli.mjs +24 -5
- package/src/browser/client.mjs +189 -25
- package/src/browser/commands.mjs +166 -51
- package/src/catalog/audit.mjs +129 -0
- package/src/catalog/auth.mjs +40 -0
- package/src/catalog/compiler.mjs +337 -0
- package/src/catalog/contracts.mjs +18 -0
- package/src/catalog/database.mjs +52 -0
- package/src/catalog/deploy.mjs +117 -0
- package/src/catalog/design.mjs +159 -0
- package/src/catalog/forms.mjs +25 -0
- package/src/catalog/hydration.mjs +29 -0
- package/src/catalog/icons.mjs +24 -0
- package/src/catalog/media.mjs +163 -0
- package/src/catalog/metadata.mjs +116 -0
- package/src/catalog/plugins.mjs +228 -0
- package/src/catalog/realtime.mjs +195 -0
- package/src/catalog/rust.mjs +75 -0
- package/src/catalog/server-functions.mjs +92 -0
- package/src/catalog/state.mjs +39 -0
- package/src/catalog/style-groups.mjs +54 -0
- package/src/catalog/tests.mjs +127 -0
- package/src/catalog/text.mjs +200 -0
- package/src/catalog/ui-state.mjs +42 -0
- package/src/catalog.mjs +67 -460
- package/src/check/runtime.mjs +280 -0
- package/src/check.mjs +198 -208
- package/src/cli/authoring.mjs +213 -0
- package/src/cli/project.mjs +302 -0
- package/src/cli.mjs +222 -250
- package/src/config.mjs +1 -1
- package/src/contributions.mjs +75 -25
- package/src/db-layer.mjs +13 -5
- package/src/deploy/cdn.mjs +94 -16
- package/src/deploy/files.mjs +100 -21
- package/src/deploy/fit.mjs +127 -23
- package/src/deploy/hosts.mjs +90 -20
- package/src/deploy/index/cli.mjs +20 -0
- package/src/deploy/index/environment.mjs +69 -0
- package/src/deploy/index/steps.mjs +87 -0
- package/src/deploy/index.mjs +228 -114
- package/src/deploy/needs.mjs +88 -31
- package/src/design-checks/accessible-names.mjs +62 -0
- package/src/design-checks/contrast.mjs +43 -0
- package/src/design-checks/csp.mjs +60 -0
- package/src/design-checks/files.mjs +59 -0
- package/src/design-checks/heading-links.mjs +61 -0
- package/src/design-checks/interaction.mjs +65 -0
- package/src/design-checks/jsx.mjs +67 -0
- package/src/design-checks/layout-values.mjs +68 -0
- package/src/design-checks/native-controls.mjs +48 -0
- package/src/design-checks/outline.mjs +39 -0
- package/src/design-checks/reporter.mjs +37 -0
- package/src/design-checks/rules.mjs +274 -0
- package/src/design-checks/values.mjs +262 -0
- package/src/design-checks.mjs +10 -400
- package/src/diag.mjs +32 -8
- package/src/docs.mjs +138 -43
- package/src/doctor.mjs +20 -8
- package/src/files.mjs +18 -5
- package/src/fix.mjs +10 -2
- package/src/gate/after.mjs +40 -0
- package/src/gate/before.mjs +54 -0
- package/src/gate/classify.mjs +72 -0
- package/src/gate/mutation.mjs +209 -0
- package/src/gate/resolve-cases.mjs +22 -0
- package/src/gate/result.mjs +63 -0
- package/src/gate/run.mjs +75 -0
- package/src/gate.mjs +104 -202
- package/src/infer/box-props.cjs +140 -0
- package/src/infer/core-boxes.cjs +46 -0
- package/src/infer/derived-writes.cjs +95 -0
- package/src/infer/engine/checks.cjs +193 -0
- package/src/infer/engine/flow.cjs +293 -0
- package/src/infer/engine/report.cjs +158 -0
- package/src/infer/engine/utils.cjs +15 -0
- package/src/infer/engine.cjs +108 -368
- package/src/infer/facts/sites.cjs +114 -0
- package/src/infer/facts.cjs +210 -117
- package/src/infer/hosts.cjs +69 -22
- package/src/infer/lsp.mjs +149 -40
- package/src/infer/session.cjs +73 -17
- package/src/infer/shadow.cjs +13 -5
- package/src/infer/worker.cjs +57 -9
- package/src/jsx-scan.mjs +63 -21
- package/src/load-config/schema.mjs +281 -0
- package/src/load-config.mjs +137 -138
- package/src/map.mjs +93 -26
- package/src/mcp/browser-tools.mjs +155 -0
- package/src/mcp.mjs +147 -69
- package/src/meta-table.mjs +43 -8
- package/src/mutate.mjs +120 -36
- package/src/plugin-checks.mjs +52 -23
- package/src/plugins.mjs +125 -39
- package/src/project.mjs +170 -79
- package/src/props-checks.mjs +24 -6
- package/src/props-listener.mjs +27 -9
- package/src/react-checks.mjs +35 -4
- package/src/server-state-checks.mjs +34 -0
- package/src/share-checks.mjs +121 -37
- package/src/site.mjs +33 -6
- package/src/slots.mjs +104 -19
- package/src/start/build.mjs +116 -0
- package/src/start/routes.mjs +38 -0
- package/src/start.mjs +193 -95
- package/src/state-checks/core-state.mjs +223 -0
- package/src/state-checks/modules.mjs +17 -0
- package/src/state-checks/scan.mjs +232 -0
- package/src/state-checks/server-graph.mjs +65 -0
- package/src/state-checks.mjs +109 -133
- package/src/style/find.mjs +121 -0
- package/src/style.mjs +133 -127
- package/src/surface-checks.mjs +212 -46
- package/src/tailwind.mjs +5 -4
- package/src/tasks.mjs +189 -27
- package/src/test-rules/context.mjs +109 -0
- package/src/test-rules/file-rules.mjs +279 -0
- package/src/test-rules/rule-helpers.mjs +244 -0
- package/src/test-rules/syntax.mjs +223 -0
- package/src/test-rules/test-rules.mjs +127 -0
- package/src/testdiff.mjs +94 -32
- package/src/tests.mjs +31 -483
- package/src/text-checks.mjs +229 -69
- package/src/tokens.mjs +12 -4
- package/src/trust/scan.mjs +95 -0
- package/src/trust.mjs +214 -102
- package/src/weft.mjs +41 -9
- package/src/workspace.mjs +96 -19
- package/ts-plugin/context.js +57 -0
- package/ts-plugin/index.js +4 -124
- package/ts-plugin/mapping.js +65 -0
- 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,
|
|
9
|
-
|
|
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
|
|
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]'>) => ({
|
|
28
|
-
|
|
29
|
-
|
|
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, {
|
|
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',
|
|
58
|
-
run: ({ ctx }) => ctx.db
|
|
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'],
|
|
65
|
-
optimistic: ({ input }, cache) =>
|
|
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
|
|
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({
|
|
115
|
-
|
|
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
|
-
|
|
151
|
-
|
|
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', {
|
|
293
|
+
export const priceTopic = topic('prices', {
|
|
294
|
+
key: z.string().max(12), message: Price,
|
|
295
|
+
})
|
|
166
296
|
export const prices = stream({
|
|
167
|
-
|
|
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',
|
|
300
|
+
auth: 'user', // optional; re-checked every 5 minutes
|
|
170
301
|
async *run({ input, signal, lastEventId }) {
|
|
171
|
-
|
|
302
|
+
// Subscribe first (R006).
|
|
303
|
+
yield* priceTopic.subscribe(input.symbols, {
|
|
304
|
+
signal, after: lastEventId,
|
|
305
|
+
})
|
|
172
306
|
},
|
|
173
307
|
})
|
|
174
|
-
//
|
|
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'] })
|
|
179
|
-
|
|
180
|
-
|
|
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({
|
|
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'],
|
|
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
|
|
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
|
-
|
|
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({
|
|
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({
|
|
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' }
|
|
291
|
-
live: { verify: { url: 'https://staging.example.dev' } }
|
|
292
|
-
|
|
293
|
-
live: { verify:
|
|
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: {
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
330
|
-
|
|
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 },
|
|
496
|
+
limits: { perSecond: 10 }, // default 20 a second per socket
|
|
334
497
|
})
|
|
335
|
-
//
|
|
498
|
+
// From a mutation or a job:
|
|
499
|
+
// await room.in({ roomId }).publish('said', row)
|
|
336
500
|
```
|
|
337
501
|
|
|
338
502
|
```tsx
|
|
339
|
-
|
|
340
|
-
const
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
chat.
|
|
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(() =>
|
|
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),
|
|
387
|
-
|
|
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)
|
|
390
|
-
|
|
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 {
|
|
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 => {
|
|
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++}>
|
|
25
|
-
|
|
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>>({}, {
|
|
70
|
-
|
|
71
|
-
|
|
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: {
|
|
13
|
-
|
|
14
|
-
|
|
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 = {
|
|
34
|
+
type Order = {
|
|
35
|
+
name: string
|
|
36
|
+
date: string
|
|
37
|
+
status: 'open' | 'shipped' | 'late'
|
|
38
|
+
}
|
|
20
39
|
|
|
21
|
-
export function OrderCard(props: {
|
|
40
|
+
export function OrderCard(props: {
|
|
41
|
+
order: Order
|
|
42
|
+
selected: boolean
|
|
43
|
+
}) {
|
|
44
|
+
const { order, selected } = props
|
|
22
45
|
return (
|
|
23
|
-
<article {...orderCard({ status:
|
|
24
|
-
<h3 {...orderCard.title}>{
|
|
25
|
-
<p {...orderCard.meta}>{
|
|
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
|
}
|