@weft-js/start 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 (213) hide show
  1. package/README.md +36 -3
  2. package/crate/Cargo.lock +1347 -0
  3. package/crate/Cargo.toml +72 -0
  4. package/crate/rust-toolchain.toml +3 -0
  5. package/crate/src/build/emit.rs +103 -0
  6. package/crate/src/build.rs +367 -0
  7. package/crate/src/ctx.rs +186 -0
  8. package/crate/src/envelope.rs +163 -0
  9. package/crate/src/functions.rs +173 -0
  10. package/crate/src/lib.rs +118 -0
  11. package/crate/src/native.rs +212 -0
  12. package/crate/src/otel.rs +62 -0
  13. package/crate/src/server.rs +203 -0
  14. package/package.json +46 -37
  15. package/src/app.mjs +38 -8
  16. package/src/asset-receipt.d.mts +13 -0
  17. package/src/asset-receipt.mjs +61 -0
  18. package/src/auth/better-auth.ts +215 -17
  19. package/src/auth/drizzle.ts +1 -1
  20. package/src/auth.ts +21 -6
  21. package/src/boot.ts +19 -8
  22. package/src/bus/postgres.ts +32 -19
  23. package/src/bus.ts +7 -4
  24. package/src/{capture.mjs → capture.ts} +34 -1
  25. package/src/changes-sql.d.mts +8 -2
  26. package/src/changes-sql.mjs +26 -10
  27. package/src/changes.ts +165 -66
  28. package/src/channel/connections.ts +216 -0
  29. package/src/channel/socket.ts +396 -0
  30. package/src/channel-fake.ts +19 -4
  31. package/src/channel.ts +120 -492
  32. package/src/client.tsx +9 -2
  33. package/src/contract/describe.mjs +231 -0
  34. package/src/contract.mjs +144 -157
  35. package/src/data/cache.ts +354 -0
  36. package/src/data/definitions.ts +94 -0
  37. package/src/data/draft.ts +104 -0
  38. package/src/data/foundation.ts +95 -0
  39. package/src/data/live-api.ts +22 -0
  40. package/src/data/mutation.ts +138 -0
  41. package/src/data/primitives.ts +135 -0
  42. package/src/data/query.ts +106 -0
  43. package/src/data/transport.ts +121 -0
  44. package/src/data/types.ts +303 -0
  45. package/src/data.ts +69 -894
  46. package/src/db/bun-sqlite.d.ts +4 -1
  47. package/src/db/changes-mysql.ts +61 -21
  48. package/src/db/changes-postgres.ts +197 -118
  49. package/src/db/changes-sqlite.ts +131 -64
  50. package/src/db/core.ts +38 -13
  51. package/src/db/index.ts +20 -4
  52. package/src/db/mysql.ts +22 -6
  53. package/src/db/postgres.ts +49 -13
  54. package/src/db/sqlite.ts +53 -27
  55. package/src/derive.d.mts +4 -1
  56. package/src/derive.mjs +136 -39
  57. package/src/dev-runtime.mjs +8 -4
  58. package/src/exec/errors.ts +57 -0
  59. package/src/exec/landing.ts +66 -0
  60. package/src/exec/scope.ts +174 -0
  61. package/src/exec/touch.ts +80 -0
  62. package/src/exec/validation.ts +176 -0
  63. package/src/exec.ts +191 -341
  64. package/src/extension.ts +8 -2
  65. package/src/first-paint.ts +26 -9
  66. package/src/form/value.ts +15 -0
  67. package/src/form.tsx +132 -43
  68. package/src/hub/channels.ts +102 -0
  69. package/src/hub/connections.ts +147 -0
  70. package/src/hub/delivery.ts +62 -0
  71. package/src/hub/feed.ts +168 -0
  72. package/src/hub/presence.ts +64 -0
  73. package/src/hub/route.ts +158 -0
  74. package/src/hub/subscriptions.ts +212 -0
  75. package/src/hub/topic.ts +98 -0
  76. package/src/hub/tracked.ts +12 -0
  77. package/src/hub/types.ts +190 -0
  78. package/src/hub/view.ts +147 -0
  79. package/src/hub.ts +132 -758
  80. package/src/index.ts +84 -10
  81. package/src/inline.mjs +356 -0
  82. package/src/inspect/compare.mjs +162 -0
  83. package/src/inspect/routes.mjs +154 -0
  84. package/src/inspect/samples.mjs +59 -0
  85. package/src/inspect/url.mjs +2 -0
  86. package/src/inspect.mjs +146 -187
  87. package/src/link/election.ts +178 -0
  88. package/src/link/lines.ts +27 -0
  89. package/src/link.ts +90 -166
  90. package/src/list-drizzle.ts +104 -18
  91. package/src/list.ts +127 -36
  92. package/src/listen.ts +32 -6
  93. package/src/live/api.ts +18 -0
  94. package/src/live/backoff.ts +6 -0
  95. package/src/live/frames.ts +42 -0
  96. package/src/live/read.ts +77 -0
  97. package/src/live/sub-types.ts +24 -0
  98. package/src/live/transport.ts +86 -0
  99. package/src/live/types.ts +52 -0
  100. package/src/live-fake.ts +20 -5
  101. package/src/live-target.mjs +112 -37
  102. package/src/live.ts +116 -225
  103. package/src/media.ts +8 -1
  104. package/src/migrations.mjs +14 -6
  105. package/src/mux.ts +161 -64
  106. package/src/og/DefaultCard.tsx +40 -9
  107. package/src/og/fonts.mjs +16 -8
  108. package/src/og/images.mjs +43 -22
  109. package/src/og/index.mjs +119 -43
  110. package/src/og/native.mjs +11 -4
  111. package/src/range.mjs +15 -3
  112. package/src/router.ts +56 -6
  113. package/src/routes.mjs +73 -22
  114. package/src/run/admission.ts +80 -0
  115. package/src/run/boot.tsx +115 -0
  116. package/src/run/cache-presentation.ts +67 -0
  117. package/src/run/cache.ts +254 -0
  118. package/src/run/helpers.ts +197 -0
  119. package/src/run/listeners.ts +155 -0
  120. package/src/run/navigation.ts +382 -0
  121. package/src/run/page-skeleton.tsx +35 -0
  122. package/src/run/prefetch.ts +128 -0
  123. package/src/run/retain-view.ts +18 -0
  124. package/src/run/shape-preparation.ts +97 -0
  125. package/src/run/shape-styles.ts +72 -0
  126. package/src/run/source-context/pin.ts +124 -0
  127. package/src/run/source-context.ts +311 -0
  128. package/src/run.tsx +50 -178
  129. package/src/rust/common.ts +163 -0
  130. package/src/rust/native.ts +282 -0
  131. package/src/rust.ts +103 -229
  132. package/src/server/card.tsx +65 -0
  133. package/src/server/compression.ts +115 -0
  134. package/src/server/core.ts +70 -0
  135. package/src/server/dispatch.ts +70 -0
  136. package/src/server/form.ts +85 -0
  137. package/src/server/functions.ts +129 -0
  138. package/src/server/handler.ts +39 -0
  139. package/src/server/head.ts +96 -0
  140. package/src/server/media.ts +60 -0
  141. package/src/server/navigation-cache.ts +80 -0
  142. package/src/server/navigation.tsx +85 -0
  143. package/src/server/page-cold.ts +95 -0
  144. package/src/server/page-stream.tsx +181 -0
  145. package/src/server/page-types.ts +51 -0
  146. package/src/server/page-warm.ts +130 -0
  147. package/src/server/page.ts +69 -0
  148. package/src/server/payload.ts +89 -0
  149. package/src/server/prerender.ts +60 -0
  150. package/src/server/preview.tsx +67 -0
  151. package/src/server/render.ts +167 -0
  152. package/src/server/request.ts +98 -0
  153. package/src/server/resolver.ts +228 -0
  154. package/src/server/routes.ts +150 -0
  155. package/src/server/services.ts +69 -0
  156. package/src/server/text.ts +29 -0
  157. package/src/server/types.ts +160 -0
  158. package/src/server.tsx +82 -971
  159. package/src/shape.mjs +30 -6
  160. package/src/share/display.ts +50 -0
  161. package/src/share/network.ts +119 -0
  162. package/src/share/receive.ts +70 -0
  163. package/src/share/state.ts +30 -0
  164. package/src/share/tab-queue.ts +28 -0
  165. package/src/share-server.ts +128 -43
  166. package/src/share.ts +103 -209
  167. package/src/sheets.mjs +30 -18
  168. package/src/sidecar/build.mjs +170 -0
  169. package/src/sidecar/cargo.mjs +164 -0
  170. package/src/sidecar/common.mjs +17 -0
  171. package/src/sidecar/dev-build.mjs +91 -0
  172. package/src/sidecar/dev-watch.mjs +88 -0
  173. package/src/sidecar/manifest.mjs +19 -0
  174. package/src/sidecar/process.mjs +58 -0
  175. package/src/sidecar/refresh.mjs +19 -0
  176. package/src/sidecar.mjs +75 -416
  177. package/src/socket/check.ts +50 -0
  178. package/src/socket/deliver.ts +16 -0
  179. package/src/socket/publish.ts +56 -0
  180. package/src/socket/rooms.ts +41 -0
  181. package/src/socket/route.ts +79 -0
  182. package/src/socket/shared.ts +26 -0
  183. package/src/socket/types.ts +48 -0
  184. package/src/socket.ts +205 -194
  185. package/src/stream.ts +10 -1
  186. package/src/verify/app.ts +39 -0
  187. package/src/verify/cleanup.ts +27 -0
  188. package/src/verify/database.ts +11 -0
  189. package/src/verify/live.ts +63 -0
  190. package/src/verify/notify.ts +35 -0
  191. package/src/verify/skip.ts +19 -0
  192. package/src/verify/support.ts +149 -0
  193. package/src/verify/timing.ts +15 -0
  194. package/src/verify/trigger.ts +14 -0
  195. package/src/verify.ts +77 -176
  196. package/src/view.tsx +96 -11
  197. package/src/vite/asset-bytes.mjs +95 -0
  198. package/src/vite/assets.mjs +117 -0
  199. package/src/vite/config-hooks.mjs +166 -0
  200. package/src/vite/dev-hooks.mjs +103 -0
  201. package/src/vite/dev.mjs +102 -0
  202. package/src/vite/entries.mjs +145 -0
  203. package/src/vite/module-hooks.mjs +288 -0
  204. package/src/vite/route-modules.mjs +289 -0
  205. package/src/vite/server-functions.mjs +222 -0
  206. package/src/vite/shape-entry-css.mjs +187 -0
  207. package/src/vite/shape-override.mjs +226 -0
  208. package/src/vite/shared.mjs +51 -0
  209. package/src/vite/sheets.mjs +92 -0
  210. package/src/vite/state.mjs +89 -0
  211. package/src/vite/syntax.mjs +139 -0
  212. package/src/vite.mjs +152 -853
  213. package/src/capture.d.mts +0 -1
package/src/exec.ts CHANGED
@@ -9,125 +9,74 @@
9
9
  // bumps the versions of the tags it invalidates).
10
10
 
11
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>
12
+ import {
13
+ enter,
14
+ fns,
15
+ isRedirect,
16
+ keyOf,
17
+ runtime,
18
+ StartError,
19
+ type Answer,
20
+ type Context,
21
+ type Fn,
22
+ type Wire,
23
+ type Life,
24
+ } from './data'
25
+
26
+ import {
27
+ createScope,
28
+ frame,
29
+ memoryStore,
30
+ defaultStore,
31
+ type Scope,
32
+ type ScopeEntry,
33
+ type Touched,
34
+ } from './exec/scope'
35
+ import { guard, dbError } from './exec/errors'
36
+ import {
37
+ tracker,
38
+ untouched,
39
+ missedTags,
40
+ reads,
41
+ liveQueries,
42
+ missedPokes,
43
+ missedFeed,
44
+ } from './exec/touch'
45
+ import { validate, formInput, checked, checkTags } from './exec/validation'
46
+ import { landed, shown, said } from './exec/landing'
47
+ export {
48
+ createScope,
49
+ frame,
50
+ memoryStore,
51
+ defaultStore,
52
+ guard,
53
+ dbError,
54
+ liveQueries,
55
+ missedFeed,
56
+ validate,
57
+ checkTags,
58
+ shown,
59
+ said,
60
+ }
61
+ export type {
62
+ Scope,
63
+ ScopeEntry,
64
+ ScopeOptions,
65
+ Touched,
66
+ FormFailure,
67
+ MediaServer,
68
+ Store,
69
+ Stored,
70
+ } from './exec/scope'
71
+
72
+ const LIFE: Record<Exclude<Life, number>, number> = {
73
+ seconds: 1,
74
+ minutes: 60,
75
+ hours: 3600,
76
+ days: 86_400,
77
+ max: Infinity,
114
78
  }
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
79
  const seconds = (life: Life = 'minutes') => (typeof life === 'number' ? life : LIFE[life])
130
- export const defaultStore = memoryStore()
131
80
  const storeOf = (scope: Scope) => scope.options.store ?? defaultStore
132
81
 
133
82
  // ---- queries --------------------------------------------------------------------------------------------------
@@ -135,8 +84,8 @@ const storeOf = (scope: Scope) => scope.options.store ?? defaultStore
135
84
  /** The value now if this request already has it, else the promise: what a query resource's fetcher returns in SSR. */
136
85
  export function readQuery(scope: Scope, fn: Fn, input: unknown): unknown {
137
86
  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
87
+ if (!e.done) return landed(scope, e)
88
+ if (e.e !== undefined) throw shown(e.e, scope)
140
89
  return e.v
141
90
  }
142
91
 
@@ -151,11 +100,16 @@ export function shapeOf(fn: Fn): unknown {
151
100
  export const fetchQuery = (scope: Scope, fn: Fn, input: unknown): Promise<ScopeEntry> => {
152
101
  const e = fetchEntry(scope, fn, input)
153
102
  const tags = scope.tags
154
- return e.p.then(x => (tags && x.tags.forEach(t => tags.add(t)), x))
103
+ return e.p.then((x) => (tags && x.tags.forEach((t) => tags.add(t)), x))
155
104
  }
156
105
 
157
106
  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')
107
+ if (fn.kind !== 'query')
108
+ throw new StartError(
109
+ 'Q009',
110
+ `\`${fn.$id}\` is a mutation, not a query`,
111
+ 'call it as a function, from a handler',
112
+ )
159
113
  const key = keyOf(fn.$id, input)
160
114
  const e = scope.entries.get(key)
161
115
  if (e) return e
@@ -168,17 +122,23 @@ function fetchEntry(scope: Scope, fn: Fn, input: unknown): ScopeEntry {
168
122
  // Checked before the shared cache, so a value stored for one user is never answered to no one.
169
123
  const early = fn.def.auth || cached?.scope ? await scope.ctx() : undefined
170
124
  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
125
+ const stored = cached?.scope
126
+ ? `${key}@${String((early as { user?: { id?: string | number } } | undefined)?.user?.id ?? '')}`
127
+ : key
172
128
  if (cached) {
173
129
  const hit = await store.get(stored)
174
- if (hit && hit.expires > Date.now() && same(hit.versions, await store.versions(hit.tags))) return hit.v
130
+ if (hit && hit.expires > Date.now() && same(hit.versions, await store.versions(hit.tags)))
131
+ return hit.v
175
132
  }
176
133
  const versions = cached ? await store.versions(entry.tags) : []
177
134
  const ctx = early ?? (await scope.ctx())
178
135
  const touched = tracker(scope)
179
136
  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))
137
+ const v = await call(scope, fn, valid, cached ? shared(ctx, fn, scope) : ctx, {
138
+ touched,
139
+ added,
140
+ })
141
+ const fresh = [...new Set(added)].filter((t) => !entry.tags.includes(t))
182
142
  if (fresh.length) {
183
143
  entry.tags.push(...fresh)
184
144
  if (cached) versions.push(...(await store.versions(fresh)))
@@ -189,15 +149,25 @@ function fetchEntry(scope: Scope, fn: Fn, input: unknown): ScopeEntry {
189
149
  missedTags(fn, 'read', touched.read, entry.tags, scope)
190
150
  reads.set(fn.$id, touched.read)
191
151
  }
192
- if (cached) await store.set(stored, { v, tags: entry.tags, versions, expires: Date.now() + seconds(cached.life) * 1000 })
152
+ if (cached)
153
+ await store.set(stored, {
154
+ v,
155
+ tags: entry.tags,
156
+ versions,
157
+ expires: Date.now() + seconds(cached.life) * 1000,
158
+ })
193
159
  return v
194
160
  })().then(
195
- v => Object.assign(entry, { done: true, v }),
196
- err => {
161
+ (v) => Object.assign(entry, { done: true, v }),
162
+ (err) => {
197
163
  if (isRedirect(err) && scope.started) {
198
164
  const message = `\`${fn.$id}\` called \`redirect('${err.href}')\` after the page started streaming: the status is already sent`
199
165
  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`)
166
+ err = new StartError(
167
+ 'Q007',
168
+ message,
169
+ `call \`${fn.$id.replace(/\..*$/, '')}.fetch()\` in the route's \`load\`, which runs before the first byte`,
170
+ )
201
171
  }
202
172
  Object.assign(entry, { done: true, e: err })
203
173
  throw err
@@ -221,34 +191,78 @@ export async function tagsOf(fn: Fn, input: unknown, scope: Pick<Scope, 'ctx'>):
221
191
  const same = (a: number[], b: number[]) => a.length === b.length && a.every((x, i) => x === b[i])
222
192
 
223
193
  /** 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 })
194
+ async function callImpl(
195
+ fn: Fn,
196
+ input: unknown,
197
+ ctx: Context,
198
+ scope: Scope,
199
+ track: { touched?: Touched | undefined; added: string[] },
200
+ ) {
201
+ const { touched, added } = track
202
+ const {
203
+ value,
204
+ tags,
205
+ touched: tables,
206
+ } = await fn.def.impl!.call({ fn, input, ctx, request: scope.request })
226
207
  added.push(...tags)
227
208
  if (touched) for (const t of tables) touched[fn.kind === 'query' ? 'read' : 'write'].add(t)
228
209
  return value
229
210
  }
230
211
 
231
- const OWN = new Set(['$id', 'auth', 'cache', 'errors', 'impl', 'input', 'invalidates', 'live', 'optimistic', 'output', 'run', 'shape', 'tags'])
212
+ const OWN = new Set([
213
+ '$id',
214
+ 'auth',
215
+ 'cache',
216
+ 'errors',
217
+ 'impl',
218
+ 'input',
219
+ 'invalidates',
220
+ 'live',
221
+ 'optimistic',
222
+ 'output',
223
+ 'run',
224
+ 'shape',
225
+ 'tags',
226
+ ])
232
227
 
233
228
  /** An option from `FnOptions` is a promise to enforce something: with the plugin gone, the call stops rather than runs open. */
234
229
  function claimed(fn: Fn, scope: Scope) {
235
230
  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 })
231
+ if (OWN.has(k) || scope.options.extensions?.some((e) => e.options?.includes(k))) continue
232
+ throw new StartError(
233
+ 'P101',
234
+ `\`${fn.$id}\` sets \`${k}\`, and no \`server.extension\` enforces it`,
235
+ 'add the plugin that provides it to `plugins`, with its `src/<key>.server.ts`',
236
+ { status: 500 },
237
+ )
238
238
  }
239
239
  }
240
240
 
241
- async function call(scope: Scope, fn: Fn, input: unknown, ctx: Context, touched?: Touched, added: string[] = []) {
241
+ async function call(
242
+ scope: Scope,
243
+ fn: Fn,
244
+ input: unknown,
245
+ ctx: Context,
246
+ track: { touched?: Touched | undefined; added?: string[] } = {},
247
+ ) {
248
+ const { touched } = track
249
+ const added = track.added ?? []
242
250
  guard(fn, ctx)
243
251
  claimed(fn, scope)
244
252
  try {
245
- for (const e of scope.options.extensions ?? []) await e.guard?.({ id: fn.$id, kind: fn.kind, def: fn.def }, ctx)
253
+ for (const e of scope.options.extensions ?? [])
254
+ await e.guard?.({ id: fn.$id, kind: fn.kind, def: fn.def }, ctx)
246
255
  } catch (e) {
247
256
  throw dbError(e)
248
257
  }
249
258
  const replaced = scope.options.handlers?.get(fn.$id)
250
259
  // 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 }))
260
+ const run = async () =>
261
+ await (replaced
262
+ ? replaced(input, ctx)
263
+ : fn.def.impl
264
+ ? callImpl(fn, input, ctx, scope, { touched, added })
265
+ : fn.def.run!({ input, ctx }))
252
266
  let v: unknown
253
267
  try {
254
268
  v = await (touched ? enter(Object.create(scope, { touched: { value: touched } }), run) : run())
@@ -260,87 +274,19 @@ async function call(scope: Scope, fn: Fn, input: unknown, ctx: Context, touched?
260
274
  return v
261
275
  }
262
276
 
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
277
  /** 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
278
  function shared(ctx: Context, fn: Fn, scope: Scope): Context {
337
279
  const own = fn.def.cache?.scope === 'user'
338
280
  return new Proxy(ctx, {
339
281
  get(t, k, r) {
340
- const personal = scope.options.extensions?.some(e => e.personal?.includes(String(k)))
282
+ const personal = scope.options.extensions?.some((e) => e.personal?.includes(String(k)))
341
283
  if (own && (k === 'user' || k === 'session' || personal)) return Reflect.get(t, k, r)
342
284
  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')
285
+ throw new StartError(
286
+ 'Q006',
287
+ `\`${fn.$id}\` is cached (\`cache: { life }\`) and reads \`ctx.${String(k)}\`: its value would be shared by every user`,
288
+ 'drop `cache`, or pass the value as input',
289
+ )
344
290
  return Reflect.get(t, k, r)
345
291
  },
346
292
  })
@@ -349,16 +295,35 @@ function shared(ctx: Context, fn: Fn, scope: Scope): Context {
349
295
  // ---- mutations --------------------------------------------------------------------------------------------------
350
296
 
351
297
  /** 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)\``)
298
+ export async function runMutation(
299
+ scope: Scope,
300
+ fn: Fn,
301
+ input: unknown,
302
+ refresh: [string, unknown][],
303
+ form?: boolean,
304
+ ): Promise<Answer> {
305
+ if (fn.kind !== 'mutation')
306
+ throw new StartError(
307
+ 'Q009',
308
+ `\`${fn.$id}\` is a query, not a mutation`,
309
+ `\`${fn.$id.replace(/\..*$/, '')}.fetch(input)\``,
310
+ )
354
311
  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 })
312
+ throw new StartError(
313
+ 'Q004',
314
+ `mutation \`${fn.$id}\` called while the server handles a GET (\`load\`, a page render or a query)`,
315
+ 'call mutations from handlers or other mutations; GET handlers must be side-effect free',
316
+ { status: 500 },
317
+ )
356
318
  const valid = await (form ? formInput(fn, input) : validate(fn, input))
357
319
  const touched = tracker(scope)
358
320
  const added: string[] = []
359
- const v = await call(scope, fn, valid, await scope.ctx(), touched, added)
321
+ const v = await call(scope, fn, valid, await scope.ctx(), { touched, added })
360
322
  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 ?? []
323
+ const declared =
324
+ typeof fn.def.invalidates === 'function'
325
+ ? fn.def.invalidates(valid, v)
326
+ : (fn.def.invalidates ?? [])
362
327
  await invalidate(scope, [...declared, ...added])
363
328
  const tags = [...scope.invalidated]
364
329
  if (touched) {
@@ -376,7 +341,7 @@ export async function runMutation(scope: Scope, fn: Fn, input: unknown, refresh:
376
341
  } catch {
377
342
  continue
378
343
  }
379
- if (!qtags.some(t => tags.includes(t))) continue
344
+ if (!qtags.some((t) => tags.includes(t))) continue
380
345
  try {
381
346
  const e = await fetchQuery(scope, q, inp)
382
347
  c.push([id, inp, e.v, e.tags])
@@ -389,131 +354,16 @@ export async function runMutation(scope: Scope, fn: Fn, input: unknown, refresh:
389
354
  export async function invalidate(scope: Scope, tags: string[]) {
390
355
  if (!tags.length) return
391
356
  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)
357
+ for (const [k, e] of scope.entries)
358
+ if (e.tags.some((t) => tags.includes(t))) scope.entries.delete(k)
393
359
  await storeOf(scope).bump(tags)
394
360
  // After the bump, and after `run`: a refetch reads committed data and misses the shared cache (research 30 §5).
395
361
  // 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
- }
362
+ await runtime.poke?.(tags, scope.request.headers.get('x-weft-conn')).catch((e) =>
363
+ scope.options.onDiagnostic?.({
364
+ code: 'E_RT',
365
+ severity: 'error',
366
+ message: `poke of ${tags.join(', ')} failed: ${e instanceof Error ? e.message : e}`,
367
+ }),
368
+ )
519
369
  }