@weft-js/start 0.0.0-stage → 0.1.1

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 (75) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +41 -2
  3. package/package.json +107 -4
  4. package/src/app.mjs +93 -0
  5. package/src/auth/better-auth.ts +58 -0
  6. package/src/auth/drizzle.ts +17 -0
  7. package/src/auth.ts +47 -0
  8. package/src/boot.ts +44 -0
  9. package/src/builtin.tsx +19 -0
  10. package/src/bus/postgres.ts +91 -0
  11. package/src/bus.ts +48 -0
  12. package/src/capture.d.mts +1 -0
  13. package/src/capture.mjs +15 -0
  14. package/src/changes-sql.d.mts +14 -0
  15. package/src/changes-sql.mjs +99 -0
  16. package/src/changes.ts +253 -0
  17. package/src/channel-fake.ts +55 -0
  18. package/src/channel.ts +622 -0
  19. package/src/client.tsx +20 -0
  20. package/src/contract.mjs +312 -0
  21. package/src/data.ts +929 -0
  22. package/src/db/bun-sqlite.d.ts +10 -0
  23. package/src/db/changes-mysql.ts +86 -0
  24. package/src/db/changes-postgres.ts +159 -0
  25. package/src/db/changes-sqlite.ts +116 -0
  26. package/src/db/core.ts +63 -0
  27. package/src/db/index.ts +27 -0
  28. package/src/db/mysql.ts +67 -0
  29. package/src/db/postgres.ts +65 -0
  30. package/src/db/sqlite.ts +139 -0
  31. package/src/derive.d.mts +16 -0
  32. package/src/derive.mjs +140 -0
  33. package/src/dev-runtime.mjs +42 -0
  34. package/src/exec.ts +519 -0
  35. package/src/extension.ts +38 -0
  36. package/src/first-paint.ts +57 -0
  37. package/src/form.tsx +134 -0
  38. package/src/gate.css +6 -0
  39. package/src/hub.ts +822 -0
  40. package/src/index.ts +106 -0
  41. package/src/inline.mjs +2 -0
  42. package/src/inspect.mjs +280 -0
  43. package/src/link.ts +461 -0
  44. package/src/list-drizzle.ts +50 -0
  45. package/src/list.ts +224 -0
  46. package/src/listen.ts +28 -0
  47. package/src/live-fake.ts +42 -0
  48. package/src/live-target.mjs +147 -0
  49. package/src/live.ts +499 -0
  50. package/src/media.ts +43 -0
  51. package/src/migrations.mjs +19 -0
  52. package/src/mux-session.ts +6 -0
  53. package/src/mux.ts +135 -0
  54. package/src/og/DefaultCard.tsx +60 -0
  55. package/src/og/fonts.mjs +64 -0
  56. package/src/og/images.mjs +54 -0
  57. package/src/og/index.mjs +146 -0
  58. package/src/og/native.mjs +29 -0
  59. package/src/range.mjs +19 -0
  60. package/src/router.ts +149 -0
  61. package/src/routes.mjs +142 -0
  62. package/src/run.tsx +202 -0
  63. package/src/rust.ts +333 -0
  64. package/src/server.tsx +1021 -0
  65. package/src/shape.d.mts +3 -0
  66. package/src/shape.mjs +73 -0
  67. package/src/share-server.ts +208 -0
  68. package/src/share.ts +503 -0
  69. package/src/sheets.mjs +75 -0
  70. package/src/sidecar.mjs +503 -0
  71. package/src/socket.ts +389 -0
  72. package/src/stream.ts +13 -0
  73. package/src/verify.ts +251 -0
  74. package/src/view.tsx +47 -0
  75. package/src/vite.mjs +899 -0
package/src/exec.ts ADDED
@@ -0,0 +1,519 @@
1
+ // The server's executor for queries and mutations (research 27 §3–§4). No runtime imports beyond data.ts, so the
2
+ // same code runs in the request handler (server.tsx, which finds the scope through AsyncLocalStorage) and in
3
+ // @weft-js/core/test's in-process fake transport.
4
+ //
5
+ // request scope key → entry (promise, then value and tags): a query runs at most once per request; loaders, every
6
+ // render pass of a streamed page and a mutation's refreshes share it. Its resolved entries are the
7
+ // page's `#weft-cache`.
8
+ // shared store only for `cache: { life }`: a value is valid while every tag's version still matches (a mutation
9
+ // bumps the versions of the tags it invalidates).
10
+
11
+ import { shapeTree } from './shape.mjs'
12
+ import type { MediaAsset } from '@weft-js/core/media'
13
+ import type { ServerExtension } from './extension'
14
+ import { FnError, enter, fns, isRedirect, keyOf, runtime, StartError, type Answer, type Context, type Fn, type Life, type SchemaIssue, type Wire } from './data'
15
+ import { unavailable } from './db/core'
16
+
17
+ export interface ScopeEntry {
18
+ id: string
19
+ input: unknown
20
+ p: Promise<ScopeEntry>
21
+ done?: boolean
22
+ v?: unknown
23
+ e?: unknown
24
+ tags: string[]
25
+ }
26
+
27
+ export interface Scope {
28
+ request: Request
29
+ entries: Map<string, ScopeEntry>
30
+ /** Tags a mutation or `invalidate()` dropped during this request. */
31
+ invalidated: Set<string>
32
+ /** Response headers the functions set (cookies), merged into the response. */
33
+ headers: Headers
34
+ /** The page's first byte went out: a redirect can't change the status any more (Q007). */
35
+ started?: boolean
36
+ /** The tags of the queries a route level's `load` read (a frame per loader; research 27 §4). */
37
+ tags?: Set<string>
38
+ /** Dev: the tables this function's statements read and wrote (a frame per `run`; research 28 §2). */
39
+ touched?: Touched
40
+ /** A no-JS form POST that failed: `<Form>` renders it into the page (research 29 §5). */
41
+ form?: FormFailure
42
+ /** The render read a live query or a stream: the page carries the realtime cursor (research 30 §1). */
43
+ live?: boolean
44
+ ctx(): Promise<Context>
45
+ options: ScopeOptions
46
+ }
47
+
48
+ export interface Touched { read: Set<string>; write: Set<string> }
49
+
50
+ export interface FormFailure { id: string; key?: string; values: Record<string, unknown>; errors: Record<string, string>; message?: string | undefined; status: number }
51
+
52
+ /** The `media.server@1` provider (`@weft-js/plugin-media/server`, research 29 §6). */
53
+ export interface MediaServer {
54
+ ingest(bytes: Uint8Array, options: { name?: string }): Promise<MediaAsset>
55
+ serve(want: { hash: string; width: number; format: string; accept: string }): Promise<{ bytes: Uint8Array; type: string; width: number; format: string; negotiated: boolean; temporary?: boolean } | null>
56
+ }
57
+
58
+ export interface ScopeOptions {
59
+ /** The app's context (`src/context.server.ts`). */
60
+ context?: ((args: { request: Request }) => object | Promise<object>) | undefined
61
+ store?: Store | undefined
62
+ /** Tests (@weft-js/core/test `net.handle`): a replacement `run` by function id. */
63
+ handlers?: Map<string, (input: unknown, ctx: Context) => unknown>
64
+ onDiagnostic?: ((d: { code: string; severity: string; message: string }) => void) | undefined
65
+ dev?: boolean | undefined
66
+ media?: (() => Promise<MediaServer> | undefined) | undefined
67
+ /** What plugins add through `server.extension` (extension.ts). */
68
+ extensions?: ServerExtension[] | undefined
69
+ }
70
+
71
+ export function createScope(request: Request, options: ScopeOptions = {}): Scope {
72
+ const headers = new Headers()
73
+ let ctx: Promise<Context> | undefined
74
+ return {
75
+ request, entries: new Map(), invalidated: new Set(), headers, options,
76
+ // oxlint-disable-next-line typescript/no-unnecessary-type-assertion -- needed where a plugin such as pay adds required fields to Context
77
+ ctx: () => (ctx ??= Promise.resolve().then(() => options.context?.({ request }) ?? {}).then(app => extended({ ...app, request, response: { headers }, media: mediaApi(options) } as Context, options), e => { throw dbError(e) })),
78
+ }
79
+ }
80
+
81
+ /** Each extension's part of `ctx`, once the app's is there: an extension reads `ctx.user` to say what it adds. */
82
+ function extended(ctx: Context, options: ScopeOptions): Context {
83
+ for (const e of options.extensions ?? []) Object.assign(ctx, e.context?.(ctx))
84
+ return ctx
85
+ }
86
+
87
+ /** `ctx.media`. A refusal is the user's answer, with its own status; `<Form>` shows it. */
88
+ const mediaApi = (options: ScopeOptions): Context['media'] => ({
89
+ async ingest(file, { name } = {}) {
90
+ const server = await options.media?.()
91
+ if (!server) throw new StartError('M017', '`ctx.media` needs a `media.server` provider', 'add `media()` from `@weft-js/plugin-media` to `plugins` in weft.config.ts', { status: 500 })
92
+ const bytes = file instanceof Uint8Array ? file : new Uint8Array(await file.arrayBuffer())
93
+ try {
94
+ return await server.ingest(bytes, { name: name ?? (file as File).name })
95
+ } catch (e) {
96
+ const { code, message } = e as Error & { code?: string }
97
+ throw code ? new StartError(code, message, undefined, { status: 422 }) : e
98
+ }
99
+ },
100
+ })
101
+
102
+ /** A child scope for one loader: the same entries, its own `tags` collector. */
103
+ export const frame = (scope: Scope): Scope => Object.create(scope, { tags: { value: new Set<string>() } })
104
+
105
+ // ---- the shared tagged cache --------------------------------------------------------------------------------------
106
+
107
+ /** Where `cache: { life }` values live. Memory by default; Nitro storage (fs, KV, Redis) plugs in here. */
108
+ export interface Store {
109
+ get(key: string): Stored | undefined | Promise<Stored | undefined>
110
+ set(key: string, value: Stored): void | Promise<void>
111
+ /** The current version of each tag (0 for one never bumped). */
112
+ versions(tags: string[]): number[] | Promise<number[]>
113
+ bump(tags: string[]): void | Promise<void>
114
+ }
115
+ export interface Stored { v: unknown; tags: string[]; versions: number[]; expires: number }
116
+
117
+ export function memoryStore(): Store {
118
+ const values = new Map<string, Stored>()
119
+ const tags = new Map<string, number>()
120
+ return {
121
+ get: k => values.get(k),
122
+ set: (k, v) => void values.set(k, v),
123
+ versions: t => t.map(x => tags.get(x) ?? 0),
124
+ bump: t => { for (const x of t) tags.set(x, (tags.get(x) ?? 0) + 1) },
125
+ }
126
+ }
127
+
128
+ const LIFE: Record<Exclude<Life, number>, number> = { seconds: 1, minutes: 60, hours: 3600, days: 86_400, max: Infinity }
129
+ const seconds = (life: Life = 'minutes') => (typeof life === 'number' ? life : LIFE[life])
130
+ export const defaultStore = memoryStore()
131
+ const storeOf = (scope: Scope) => scope.options.store ?? defaultStore
132
+
133
+ // ---- queries --------------------------------------------------------------------------------------------------
134
+
135
+ /** The value now if this request already has it, else the promise: what a query resource's fetcher returns in SSR. */
136
+ export function readQuery(scope: Scope, fn: Fn, input: unknown): unknown {
137
+ const e = fetchEntry(scope, fn, input)
138
+ if (!e.done) return e.p.then(x => x.v)
139
+ if (e.e !== undefined) throw e.e as Error
140
+ return e.v
141
+ }
142
+
143
+ const trees = new WeakMap<Fn, { tree: unknown }>()
144
+ /** A query's shape tree, once per definition. */
145
+ export function shapeOf(fn: Fn): unknown {
146
+ let t = trees.get(fn)
147
+ if (!t) trees.set(fn, (t = { tree: shapeTree(fn.def) }))
148
+ return t.tree
149
+ }
150
+
151
+ export const fetchQuery = (scope: Scope, fn: Fn, input: unknown): Promise<ScopeEntry> => {
152
+ const e = fetchEntry(scope, fn, input)
153
+ const tags = scope.tags
154
+ return e.p.then(x => (tags && x.tags.forEach(t => tags.add(t)), x))
155
+ }
156
+
157
+ function fetchEntry(scope: Scope, fn: Fn, input: unknown): ScopeEntry {
158
+ if (fn.kind !== 'query') throw new StartError('Q009', `\`${fn.$id}\` is a mutation, not a query`, 'call it as a function, from a handler')
159
+ const key = keyOf(fn.$id, input)
160
+ const e = scope.entries.get(key)
161
+ if (e) return e
162
+ const entry: ScopeEntry = { id: fn.$id, input, tags: [], p: undefined as never }
163
+ entry.p = (async () => {
164
+ const valid = await validate(fn, input)
165
+ entry.tags = await tagsOf(fn, valid, scope)
166
+ const cached = fn.def.cache
167
+ const store = storeOf(scope)
168
+ // Checked before the shared cache, so a value stored for one user is never answered to no one.
169
+ const early = fn.def.auth || cached?.scope ? await scope.ctx() : undefined
170
+ if (early) guard(fn, cached ? shared(early, fn, scope) : early)
171
+ const stored = cached?.scope ? `${key}@${String((early as { user?: { id?: string | number } } | undefined)?.user?.id ?? '')}` : key
172
+ if (cached) {
173
+ const hit = await store.get(stored)
174
+ if (hit && hit.expires > Date.now() && same(hit.versions, await store.versions(hit.tags))) return hit.v
175
+ }
176
+ const versions = cached ? await store.versions(entry.tags) : []
177
+ const ctx = early ?? (await scope.ctx())
178
+ const touched = tracker(scope)
179
+ const added: string[] = []
180
+ const v = await call(scope, fn, valid, cached ? shared(ctx, fn, scope) : ctx, touched, added)
181
+ const fresh = [...new Set(added)].filter(t => !entry.tags.includes(t))
182
+ if (fresh.length) {
183
+ entry.tags.push(...fresh)
184
+ if (cached) versions.push(...(await store.versions(fresh)))
185
+ }
186
+ if (scope.options.dev) await checked(v, fn, scope)
187
+ if (touched) {
188
+ untouched(fn, touched.read, entry.tags, scope)
189
+ missedTags(fn, 'read', touched.read, entry.tags, scope)
190
+ reads.set(fn.$id, touched.read)
191
+ }
192
+ if (cached) await store.set(stored, { v, tags: entry.tags, versions, expires: Date.now() + seconds(cached.life) * 1000 })
193
+ return v
194
+ })().then(
195
+ v => Object.assign(entry, { done: true, v }),
196
+ err => {
197
+ if (isRedirect(err) && scope.started) {
198
+ const message = `\`${fn.$id}\` called \`redirect('${err.href}')\` after the page started streaming: the status is already sent`
199
+ scope.options.onDiagnostic?.({ code: 'Q007', severity: 'error', message })
200
+ err = new StartError('Q007', message, `call \`${fn.$id.replace(/\..*$/, '')}.fetch()\` in the route's \`load\`, which runs before the first byte`)
201
+ }
202
+ Object.assign(entry, { done: true, e: err })
203
+ throw err
204
+ },
205
+ )
206
+ entry.p.catch(() => {}) // a failure surfaces where the value is read
207
+ scope.entries.set(key, entry)
208
+ return entry
209
+ }
210
+
211
+ /** A query's tags. One that takes `ctx` is one user's, so it reads `ctx`; a signed-out caller fails here, as `guard` would. */
212
+ export async function tagsOf(fn: Fn, input: unknown, scope: Pick<Scope, 'ctx'>): Promise<string[]> {
213
+ const tags = fn.def.tags
214
+ if (typeof tags !== 'function') return [...(tags ?? [])]
215
+ if (tags.length < 2) return [...tags(input)]
216
+ const ctx = await scope.ctx()
217
+ guard(fn, ctx)
218
+ return [...tags(input, ctx)]
219
+ }
220
+
221
+ const same = (a: number[], b: number[]) => a.length === b.length && a.every((x, i) => x === b[i])
222
+
223
+ /** A body in another process (research 36): what it adds to the tags and reports it touched arrive with the value. */
224
+ async function callImpl(fn: Fn, input: unknown, ctx: Context, scope: Scope, touched: Touched | undefined, added: string[]) {
225
+ const { value, tags, touched: tables } = await fn.def.impl!.call({ fn, input, ctx, request: scope.request })
226
+ added.push(...tags)
227
+ if (touched) for (const t of tables) touched[fn.kind === 'query' ? 'read' : 'write'].add(t)
228
+ return value
229
+ }
230
+
231
+ const OWN = new Set(['$id', 'auth', 'cache', 'errors', 'impl', 'input', 'invalidates', 'live', 'optimistic', 'output', 'run', 'shape', 'tags'])
232
+
233
+ /** An option from `FnOptions` is a promise to enforce something: with the plugin gone, the call stops rather than runs open. */
234
+ function claimed(fn: Fn, scope: Scope) {
235
+ for (const k in fn.def) {
236
+ if (OWN.has(k) || scope.options.extensions?.some(e => e.options?.includes(k))) continue
237
+ throw new StartError('P101', `\`${fn.$id}\` sets \`${k}\`, and no \`server.extension\` enforces it`, 'add the plugin that provides it to `plugins`, with its `src/<key>.server.ts`', { status: 500 })
238
+ }
239
+ }
240
+
241
+ async function call(scope: Scope, fn: Fn, input: unknown, ctx: Context, touched?: Touched, added: string[] = []) {
242
+ guard(fn, ctx)
243
+ claimed(fn, scope)
244
+ try {
245
+ for (const e of scope.options.extensions ?? []) await e.guard?.({ id: fn.$id, kind: fn.kind, def: fn.def }, ctx)
246
+ } catch (e) {
247
+ throw dbError(e)
248
+ }
249
+ const replaced = scope.options.handlers?.get(fn.$id)
250
+ // Awaited inside the frame: a Drizzle query is a thenable, and only runs when something awaits it.
251
+ const run = async () => await (replaced ? replaced(input, ctx) : fn.def.impl ? callImpl(fn, input, ctx, scope, touched, added) : fn.def.run!({ input, ctx }))
252
+ let v: unknown
253
+ try {
254
+ v = await (touched ? enter(Object.create(scope, { touched: { value: touched } }), run) : run())
255
+ } catch (e) {
256
+ throw dbError(e)
257
+ }
258
+ // oxlint-disable-next-line typescript/only-throw-error -- a returned redirect takes the same path as a thrown one
259
+ if (isRedirect(v)) throw v
260
+ return v
261
+ }
262
+
263
+ /** `auth: 'user'`: A003 when `ctx` has no `user` at all, A002 when no one is signed in. */
264
+ export function guard(fn: Fn, ctx: Context) {
265
+ if (fn.def.auth !== 'user') return
266
+ if (!('user' in ctx)) throw new StartError('A003', `\`${fn.$id}\` has \`auth: 'user'\`, but \`ctx\` has no \`user\``, 'spread `session(auth, request)` into what `src/context.server.ts` returns', { status: 500 })
267
+ if (!(ctx as { user?: unknown }).user) throw new StartError('A002', `\`${fn.$id}\` needs a signed-in user`, 'sign in first, or guard the route with `redirect()` in its `load`', { status: 401 })
268
+ }
269
+
270
+ const DOWN = /^(ECONNREFUSED|ECONNRESET|ENOTFOUND|EHOSTUNREACH|ETIMEDOUT|CONNECT_TIMEOUT|CONNECTION_(CLOSED|ENDED|DESTROYED)|PROTOCOL_CONNECTION_LOST|P100[12])$/
271
+
272
+ interface Failure { cause?: Failure | null; answer?: { message?: string; code?: string; status: number }; code?: string | number; message?: string }
273
+
274
+ /** A dropped connection is Q016; a missing table is a migration that wasn't generated or applied (Q014): the driver's error, with the fix. */
275
+ export function dbError(e: unknown): unknown {
276
+ const failure = e as Failure | null | undefined
277
+ const cause = failure?.cause ?? failure
278
+ if (failure?.answer) return new FnError(failure.answer, failure.answer.status)
279
+ if (cause instanceof StartError) return cause // Drizzle wraps what a driver throws, the gate's Q016 included
280
+ if (!(e instanceof StartError) && DOWN.test(String(cause?.code ?? failure?.code ?? ''))) return unavailable(e)
281
+ const m = cause?.message ?? ''
282
+ if (e instanceof StartError || !(/no such (table|column)|has no column named/.test(m) || cause?.code === '42P01' || cause?.code === '42703')) return e
283
+ return new StartError('Q014', `the database doesn't match the schema: ${m}`, 'run `weft db generate`, then reload (new migrations apply on the next request)', { status: 500 })
284
+ }
285
+
286
+ type Obj = Record<PropertyKey, unknown>
287
+
288
+ const tracker = (scope: Scope): Touched | undefined => (scope.options.dev ? { read: new Set(), write: new Set() } : undefined)
289
+
290
+ /** R105 (dev, once per function): a cached Rust query with tags that named no table, so Q011 has nothing to check it by. */
291
+ function untouched(fn: Fn, read: Set<string>, tags: readonly string[], scope: Scope) {
292
+ if (!fn.def.impl || !fn.def.cache || read.size || !tags.length || warned.has(`${fn.$id}:R105`)) return
293
+ warned.add(`${fn.$id}:R105`)
294
+ scope.options.onDiagnostic?.({ code: 'R105', severity: 'warning', message: `\`${fn.$id}\` is cached under ${tags.map(t => `\`${t}\``).join(', ')} and reported no table: nothing can tell whether those tags cover what it reads. Call \`ctx.touch("table")\` in the Rust, or drop \`tags\` if it reads none` })
295
+ }
296
+
297
+ /** Q011 (dev, once per function and table): a table the function touched that no tag names (PLAN §4). */
298
+ function missedTags(fn: Fn, verb: 'read' | 'write', tables: Set<string>, tags: readonly string[], scope: Scope) {
299
+ for (const t of tables) {
300
+ const key = `${fn.$id}:${t}`
301
+ if (warned.has(key) || tags.some(x => x === t || x.startsWith(`${t}:`))) continue
302
+ warned.add(key)
303
+ const list = fn.kind === 'query' ? '`tags`' : '`invalidates`'
304
+ scope.options.onDiagnostic?.({ code: 'Q011', severity: 'warning', message: `\`${fn.$id}\` ${verb === 'read' ? 'reads' : 'writes'} table \`${t}\`, but its ${list} don't name it: ${fn.kind === 'query' ? 'a mutation of that table won\'t refresh it' : 'queries of that table stay stale'}. Add \`'${t}'\` to ${list}` })
305
+ }
306
+ }
307
+ const warned = new Set<string>()
308
+
309
+ /** Dev: the tables each query last read, and the tags of the queries read with `.live()` (the hub fills it). */
310
+ const reads = new Map<string, Set<string>>()
311
+ export const liveQueries = new Map<string, string[]>()
312
+
313
+ /** R003 (dev, once per pair): this mutation wrote what a live query reads, and pokes none of its tags. */
314
+ function missedPokes(fn: Fn, write: Set<string>, tags: string[], scope: Scope) {
315
+ for (const [id, own] of liveQueries) {
316
+ const table = [...(reads.get(id) ?? [])].find(t => write.has(t))
317
+ const key = `${fn.$id}>${id}`
318
+ if (!table || warned.has(key) || own.some(t => tags.includes(t))) continue
319
+ warned.add(key)
320
+ scope.options.onDiagnostic?.({ code: 'R003', severity: 'warning', message: `\`${fn.$id}\` writes table \`${table}\`, which live query \`${id}\` reads, but their tags don't intersect (${JSON.stringify(tags)} and ${JSON.stringify(own)}): open pages won't update. Add one of the query's tags to \`invalidates\`` })
321
+ }
322
+ }
323
+
324
+ /** R012 (dev, once per query and table): a live query whose tags won't hear what `changes` reports, or a table it doesn't report. */
325
+ export function missedFeed(fn: Fn, tags: string[], tables: string[], report: ScopeOptions['onDiagnostic']) {
326
+ for (const t of reads.get(fn.$id) ?? []) {
327
+ const key = `${fn.$id}~${t}`
328
+ const named = tags.some(x => x === t || x.startsWith(`${t}:`))
329
+ if (warned.has(key) || (named && tables.includes(t))) continue
330
+ warned.add(key)
331
+ report?.({ code: 'R012', severity: 'warning', message: tables.includes(t) ? `live query \`${fn.$id}\` reads table \`${t}\`, but no tag names it: writes from outside the app won't refresh it. Add \`'${t}'\` to its \`tags\`` : `live query \`${fn.$id}\` reads table \`${t}\`, which \`changes\` doesn't list: only mutations refresh it, not writes from outside the app. Add the table to \`dbChanges\`` })
332
+ }
333
+ }
334
+
335
+ /** A cached query's ctx: no request, response or user. Its value is shared by every user (Q006). With `scope: 'user'`, one value per user: `ctx.user` is allowed. */
336
+ function shared(ctx: Context, fn: Fn, scope: Scope): Context {
337
+ const own = fn.def.cache?.scope === 'user'
338
+ return new Proxy(ctx, {
339
+ get(t, k, r) {
340
+ const personal = scope.options.extensions?.some(e => e.personal?.includes(String(k)))
341
+ if (own && (k === 'user' || k === 'session' || personal)) return Reflect.get(t, k, r)
342
+ if (k === 'request' || k === 'response' || k === 'user' || k === 'session' || personal)
343
+ throw new StartError('Q006', `\`${fn.$id}\` is cached (\`cache: { life }\`) and reads \`ctx.${String(k)}\`: its value would be shared by every user`, 'drop `cache`, or pass the value as input')
344
+ return Reflect.get(t, k, r)
345
+ },
346
+ })
347
+ }
348
+
349
+ // ---- mutations --------------------------------------------------------------------------------------------------
350
+
351
+ /** Run a mutation; then refresh the listed queries whose tags it invalidated (single-flight, research 27 §3). */
352
+ export async function runMutation(scope: Scope, fn: Fn, input: unknown, refresh: [string, unknown][], form?: boolean): Promise<Answer> {
353
+ if (fn.kind !== 'mutation') throw new StartError('Q009', `\`${fn.$id}\` is a query, not a mutation`, `\`${fn.$id.replace(/\..*$/, '')}.fetch(input)\``)
354
+ if (scope.request.method === 'GET' || scope.request.method === 'HEAD')
355
+ throw new StartError('Q004', `mutation \`${fn.$id}\` called while the server handles a GET (\`load\`, a page render or a query)`, 'call mutations from handlers or other mutations; GET handlers must be side-effect free', { status: 500 })
356
+ const valid = await (form ? formInput(fn, input) : validate(fn, input))
357
+ const touched = tracker(scope)
358
+ const added: string[] = []
359
+ const v = await call(scope, fn, valid, await scope.ctx(), touched, added)
360
+ if (scope.options.dev) await checked(v, fn, scope)
361
+ const declared = typeof fn.def.invalidates === 'function' ? fn.def.invalidates(valid, v) : fn.def.invalidates ?? []
362
+ await invalidate(scope, [...declared, ...added])
363
+ const tags = [...scope.invalidated]
364
+ if (touched) {
365
+ missedTags(fn, 'write', touched.write, tags, scope)
366
+ missedPokes(fn, touched.write, tags, scope)
367
+ }
368
+ const c: Wire[] = []
369
+ for (const [id, inp] of refresh) {
370
+ const q = fns.get(id)
371
+ if (!q || q.kind !== 'query') continue
372
+ let qtags: string[]
373
+ try {
374
+ const vi = await validate(q, inp)
375
+ qtags = await tagsOf(q, vi, scope)
376
+ } catch {
377
+ continue
378
+ }
379
+ if (!qtags.some(t => tags.includes(t))) continue
380
+ try {
381
+ const e = await fetchQuery(scope, q, inp)
382
+ c.push([id, inp, e.v, e.tags])
383
+ } catch {} // the client refetches it and sees the error there
384
+ }
385
+ return { v, t: tags, c }
386
+ }
387
+
388
+ /** Drop `tags` for the rest of this request and bump them in the shared store. */
389
+ export async function invalidate(scope: Scope, tags: string[]) {
390
+ if (!tags.length) return
391
+ for (const t of tags) scope.invalidated.add(t)
392
+ for (const [k, e] of scope.entries) if (e.tags.some(t => tags.includes(t))) scope.entries.delete(k)
393
+ await storeOf(scope).bump(tags)
394
+ // After the bump, and after `run`: a refetch reads committed data and misses the shared cache (research 30 §5).
395
+ // A bus that's down costs open pages a refresh, not this request its result.
396
+ await runtime.poke?.(tags, scope.request.headers.get('x-weft-conn')).catch(e => scope.options.onDiagnostic?.({ code: 'E_RT', severity: 'error', message: `poke of ${tags.join(', ')} failed: ${e instanceof Error ? e.message : e}` }))
397
+ }
398
+
399
+ // ---- validation and checks ------------------------------------------------------------------------------------
400
+
401
+ export async function validate(fn: Fn, input: unknown): Promise<unknown> {
402
+ const schema = fn.def.input
403
+ if (!schema) return input
404
+ const r = await schema['~standard'].validate(input)
405
+ if (!r.issues) return r.value
406
+ throw rejected(fn, r.issues)
407
+ }
408
+
409
+ const plainPath = (i: SchemaIssue) => (i.path ?? []).map(p => (typeof p === 'object' ? p.key : p))
410
+
411
+ function rejected(fn: Fn, list: readonly SchemaIssue[]) {
412
+ const issues = list.map(i => ({ message: i.message, path: i.path && plainPath(i) })) as (SchemaIssue & { path?: PropertyKey[] })[]
413
+ const where = issues.map(i => `${i.path?.length ? i.path.map(String).join('.') + ': ' : ''}${i.message}`).join('; ')
414
+ return new StartError('Q002', `\`${fn.$id}\` rejected its input: ${where}`, 'pass what the schema accepts', { status: 400, issues })
415
+ }
416
+
417
+ /** A form's string as the type it may stand for (research 29 §5): the schema says whether it was wrong. */
418
+ function reading(s: string): unknown {
419
+ if (s === '') return undefined
420
+ if (s === 'on' || s === 'true') return true
421
+ if (s === 'false') return false
422
+ return /^-?\d+(\.\d+)?$/.test(s) ? Number(s) : s
423
+ }
424
+
425
+ /**
426
+ * A form sends strings and a schema may want numbers or booleans, and Standard Schema can't say which: each issue
427
+ * on a string tries its obvious reading, then the input is validated again. Fields a reading fixed report nothing; the
428
+ * rest report what the schema says of what was typed (an empty field: its "required", not "expected a string").
429
+ */
430
+ async function formInput(fn: Fn, input: unknown): Promise<unknown> {
431
+ const schema = fn.def.input
432
+ if (!schema || !input || typeof input !== 'object') return validate(fn, input)
433
+ const value: Obj = { ...input }
434
+ let left: readonly SchemaIssue[] = []
435
+ for (let i = 0; i < 3; i++) {
436
+ const r = await schema['~standard'].validate(value)
437
+ if (!r.issues) return r.value
438
+ left = r.issues
439
+ let changed = false
440
+ for (const issue of r.issues) {
441
+ const path = plainPath(issue)
442
+ if (!path.length) continue
443
+ let at: Obj | undefined = value
444
+ for (const p of path.slice(0, -1)) {
445
+ const inner: unknown = at?.[p]
446
+ if (!inner || typeof inner !== 'object') { at = undefined; break }
447
+ at = at[p] = (Array.isArray(inner) ? [...inner] : { ...inner }) as Obj
448
+ }
449
+ const k = path.at(-1)!
450
+ const typed = at?.[k]
451
+ if (typeof typed !== 'string') continue
452
+ const next = reading(typed)
453
+ if (next === typed) continue
454
+ if (next === undefined) delete at![k]
455
+ else at![k] = next
456
+ changed = true
457
+ }
458
+ if (!changed) break
459
+ }
460
+ const first = await schema['~standard'].validate(input)
461
+ const where = (i: SchemaIssue) => JSON.stringify(plainPath(i))
462
+ throw rejected(fn, left.map(i => first.issues?.find(o => where(o) === where(i)) ?? i))
463
+ }
464
+
465
+ /** Dev and tests: the value against `output` (C002), and what JSON would change (Q005). */
466
+ async function checked(v: unknown, fn: Fn, scope: Scope) {
467
+ serializable(v, fn, scope)
468
+ const schema = fn.def.output
469
+ if (!schema || fn.def.impl) return // `impl`: rust.ts checked what came back, on every call
470
+ const r = await schema['~standard'].validate(v)
471
+ if (r.issues) throw new StartError('C002', `\`${fn.$id}\` returned a value its \`output\` schema rejects: ${r.issues.map(i => `${i.path?.map(p => (typeof p === 'object' ? p.key : p)).join('.') ?? ''}: ${i.message}`).join('; ')}`, 'fix `run`, or the schema', { status: 500 })
472
+ }
473
+
474
+ /** Q005 (dev): what JSON would change on the way to the client. */
475
+ function serializable(v: unknown, fn: Fn, scope: Scope) {
476
+ const bad = (x: unknown, path: string): string | undefined => {
477
+ if (x === null || typeof x === 'string' || typeof x === 'boolean') return undefined
478
+ if (typeof x === 'number') return Number.isFinite(x) ? undefined : `${path} is ${x}`
479
+ if (x === undefined) return undefined
480
+ if (typeof x !== 'object') return `${path} is a ${typeof x}`
481
+ if (Array.isArray(x)) {
482
+ for (let i = 0; i < x.length; i++) {
483
+ if (x[i] === undefined) return `${path}[${i}] is undefined (JSON sends null)`
484
+ const b = bad(x[i], `${path}[${i}]`)
485
+ if (b) return b
486
+ }
487
+ return undefined
488
+ }
489
+ const proto: unknown = Object.getPrototypeOf(x)
490
+ if (proto !== Object.prototype && proto !== null) return `${path} is a ${(x as { constructor?: { name?: string } }).constructor?.name ?? 'class instance'}`
491
+ for (const k of Object.keys(x)) {
492
+ const b = bad((x as Obj)[k], `${path}.${k}`)
493
+ if (b) return b
494
+ }
495
+ return undefined
496
+ }
497
+ const b = bad(v, 'result')
498
+ if (b) scope.options.onDiagnostic?.({ code: 'Q005', severity: 'warning', message: `\`${fn.$id}\`'s ${b}: the client gets a different value than the server rendered with. Send plain data (ISO strings, arrays, objects)` })
499
+ }
500
+
501
+ /**
502
+ * Q008 (dev, once): each mutation's static `invalidates` against the tags queries declare. A tags function is called
503
+ * with an input whose every field reads `*`, so `todo:${id}` declares `todo:*`.
504
+ */
505
+ export function checkTags(report: (d: { code: string; severity: string; message: string }) => void) {
506
+ const star: unknown = new Proxy({}, { get: (_, k) => (k === Symbol.toPrimitive ? () => '*' : '*') })
507
+ const declared: string[] = []
508
+ for (const f of fns.values()) {
509
+ if (f.kind !== 'query') continue
510
+ try { declared.push(...(typeof f.def.tags === 'function' ? f.def.tags(star) : f.def.tags ?? [])) } catch {}
511
+ }
512
+ const matches = (tag: string) => declared.some(d => d === tag || (d.includes('*') && new RegExp(`^${d.replace(/[.+?^${}()|[\]\\]/g, '\\$&').replace(/\*/g, '[^]*')}$`).test(tag)) || (tag.includes('*') && d.startsWith(tag.slice(0, tag.indexOf('*')))))
513
+ for (const f of fns.values()) {
514
+ if (f.kind !== 'mutation' || !f.def.invalidates) continue
515
+ let tags: readonly string[]
516
+ try { tags = typeof f.def.invalidates === 'function' ? f.def.invalidates(star, star) : f.def.invalidates } catch { continue }
517
+ for (const t of tags) if (!matches(t)) report({ code: 'Q008', severity: 'warning', message: `\`${f.$id}\` invalidates \`${t}\`, a tag no query declares: nothing refreshes. Fix the tag, or add it to the query's \`tags\`` })
518
+ }
519
+ }
@@ -0,0 +1,38 @@
1
+ // `server.extension@1`: how a plugin reaches the server without a private path. One provider per key adds what it needs,
2
+ // and only an app with `src/<key>.server.ts` runs it, so nothing arrives behind the app's back (P118). The provider exports
3
+ // a function from that file's default export to a `ServerExtension`; `weft docs plugins` has a worked example.
4
+ //
5
+ // Four things, each optional. Routes live under `/_weft/x/<key>/`. `context` adds to `ctx`. `guard` runs before every server
6
+ // function, and options it reads come from augmenting `FnOptions`. `check` runs before the first request.
7
+
8
+ import type { Context } from './data'
9
+
10
+ /** The path prefix an extension's routes answer under. */
11
+ export const EXTENSION = '/_weft/x/'
12
+
13
+ /** What a server extension is handed. */
14
+ export interface ExtensionApi {
15
+ /** Drop these tags, as a mutation would: live readers refetch. */
16
+ invalidate(tags: string[]): Promise<void>
17
+ /** The server log. */
18
+ report(d: { code: string; severity: string; message: string }): void
19
+ }
20
+
21
+ /** A plugin's server half: its routes, what it adds to `ctx`, and what it guards. */
22
+ export interface ServerExtension {
23
+ /** Serves `/_weft/x/<key>/<path>`. It sees the raw request: nothing has read the body. */
24
+ handle?(request: Request, path: string, api: ExtensionApi): Promise<Response> | Response
25
+ /** Merged into `ctx`, after the app's own. */
26
+ context?(ctx: Context): object
27
+ /** Before a function's `run`. Throw to refuse: an error with `answer: { code, message, status }` reaches the client as it is. */
28
+ guard?(fn: { id: string; kind: string; def: Record<string, unknown> }, ctx: Context): void | Promise<void>
29
+ /** The `FnOptions` keys it enforces. One no extension claims refuses the call: a guard that isn't there mustn't pass. */
30
+ options?: readonly string[]
31
+ /** The `ctx` keys that differ per user, which a shared cache may not read (Q006). */
32
+ personal?: readonly string[]
33
+ /** Before the first request, never in a build: throw for a server that can't run safely. */
34
+ check?(dev?: boolean): void
35
+ }
36
+
37
+ /** One extension in the generated list, loaded when first asked for. */
38
+ export interface ExtensionEntry { key: string; load(): Promise<ServerExtension> }
@@ -0,0 +1,57 @@
1
+ // The first-paint CSS of whatever HTML is passed in, by lookups: the build's `pieces` table, nothing parsed here. It reads
2
+ // the HTML rather than a list kept while rendering, so a cached or prerendered page, a static level and a streamed chunk
3
+ // take one path, nothing is shared between requests, and the same HTML always gets the same CSS.
4
+ import { markup } from '@weft-js/core/markup'
5
+
6
+ export interface Pieces {
7
+ names: string[]; layers: string; wraps: string[]; pieces: [number, number, string, string][]
8
+ always: number[]; alts: ([string[], string[][]][] | null)[]; deps: number[][]; index: Record<string, number[]>
9
+ }
10
+
11
+ const GATE = '[data-weft=styled]{visibility:hidden;animation:weft-gate 0s 3s forwards}@keyframes weft-gate{to{visibility:visible}}'
12
+ const depth = (open: string) => open.split('{').length - 1
13
+
14
+ /**
15
+ * One response's tracker: each call gives the CSS `html` needs that this response hasn't sent, from `sheets` alone, so
16
+ * another route's rules never reach this page. `extra`: what the page's head sets, in `markup`'s words.
17
+ */
18
+ export function firstPaint(table: Pieces, sheets: string[], head = '') {
19
+ const rank = new Map(sheets.map((name, i) => [table.names.indexOf(name), i]))
20
+ const sent = new Set<number>()
21
+ const headHas = markup('')
22
+ for (const [, name, value] of head.matchAll(/dataset\.(\w+)\s*=\s*['"]([^'"]+)['"]/g)) headHas.add(`[data-${name}=${value}]`)
23
+ let gated = false
24
+ return (html: string) => {
25
+ const has = markup(html)
26
+ for (const k of headHas) has.add(k)
27
+ const fresh: number[] = []
28
+ const take = (id: number) => {
29
+ if (sent.has(id) || !rank.has(table.pieces[id]![0])) return
30
+ sent.add(id)
31
+ fresh.push(id)
32
+ table.deps[id]!.forEach(take)
33
+ }
34
+ table.always.forEach(take)
35
+ for (const key of has) table.index[key]?.forEach(id => {
36
+ const need = table.alts[id]
37
+ if (!need || need.some(([all, any]) => all.every(k => has.has(k)) && any.every(l => l.some(k => has.has(k))))) take(id)
38
+ })
39
+ let css = !gated && has.has('[data-weft=styled]') ? ((gated = true), GATE) : ''
40
+ fresh.sort((a, b) => rank.get(table.pieces[a]![0])! - rank.get(table.pieces[b]![0])! || a - b)
41
+ let open = 0, rule = ''
42
+ for (const id of fresh) {
43
+ const [, wrap, selector, body] = table.pieces[id]!
44
+ if (wrap !== open) {
45
+ css += (rule && '}') + '}'.repeat(depth(table.wraps[open]!)) + table.wraps[wrap]
46
+ open = wrap
47
+ rule = ''
48
+ }
49
+ if (selector === rule && selector[0] !== '@') css += `;${body}` // a token block: one head, however many of its tokens are in
50
+ else {
51
+ css += `${rule && '}'}${selector}{${body}`
52
+ rule = selector
53
+ }
54
+ }
55
+ return css + (rule && '}') + '}'.repeat(depth(table.wraps[open]!))
56
+ }
57
+ }