@weft-js/start 0.1.1 → 0.1.3

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 (214) hide show
  1. package/README.md +1278 -12
  2. package/crate/Cargo.lock +1347 -0
  3. package/crate/Cargo.toml +72 -0
  4. package/crate/README.md +322 -0
  5. package/crate/rust-toolchain.toml +3 -0
  6. package/crate/src/build/emit.rs +103 -0
  7. package/crate/src/build.rs +367 -0
  8. package/crate/src/ctx.rs +186 -0
  9. package/crate/src/envelope.rs +163 -0
  10. package/crate/src/functions.rs +173 -0
  11. package/crate/src/lib.rs +118 -0
  12. package/crate/src/native.rs +212 -0
  13. package/crate/src/otel.rs +62 -0
  14. package/crate/src/server.rs +203 -0
  15. package/package.json +46 -37
  16. package/src/app.mjs +38 -8
  17. package/src/asset-receipt.d.mts +13 -0
  18. package/src/asset-receipt.mjs +61 -0
  19. package/src/auth/better-auth.ts +215 -17
  20. package/src/auth/drizzle.ts +1 -1
  21. package/src/auth.ts +21 -6
  22. package/src/boot.ts +19 -8
  23. package/src/bus/postgres.ts +32 -19
  24. package/src/bus.ts +7 -4
  25. package/src/{capture.mjs → capture.ts} +34 -1
  26. package/src/changes-sql.d.mts +8 -2
  27. package/src/changes-sql.mjs +26 -10
  28. package/src/changes.ts +165 -66
  29. package/src/channel/connections.ts +216 -0
  30. package/src/channel/socket.ts +396 -0
  31. package/src/channel-fake.ts +19 -4
  32. package/src/channel.ts +120 -492
  33. package/src/client.tsx +9 -2
  34. package/src/contract/describe.mjs +231 -0
  35. package/src/contract.mjs +144 -157
  36. package/src/data/cache.ts +354 -0
  37. package/src/data/definitions.ts +94 -0
  38. package/src/data/draft.ts +104 -0
  39. package/src/data/foundation.ts +95 -0
  40. package/src/data/live-api.ts +22 -0
  41. package/src/data/mutation.ts +138 -0
  42. package/src/data/primitives.ts +135 -0
  43. package/src/data/query.ts +106 -0
  44. package/src/data/transport.ts +121 -0
  45. package/src/data/types.ts +303 -0
  46. package/src/data.ts +69 -894
  47. package/src/db/bun-sqlite.d.ts +4 -1
  48. package/src/db/changes-mysql.ts +61 -21
  49. package/src/db/changes-postgres.ts +197 -118
  50. package/src/db/changes-sqlite.ts +131 -64
  51. package/src/db/core.ts +38 -13
  52. package/src/db/index.ts +20 -4
  53. package/src/db/mysql.ts +22 -6
  54. package/src/db/postgres.ts +49 -13
  55. package/src/db/sqlite.ts +53 -27
  56. package/src/derive.d.mts +4 -1
  57. package/src/derive.mjs +136 -39
  58. package/src/dev-runtime.mjs +8 -4
  59. package/src/exec/errors.ts +57 -0
  60. package/src/exec/landing.ts +66 -0
  61. package/src/exec/scope.ts +174 -0
  62. package/src/exec/touch.ts +80 -0
  63. package/src/exec/validation.ts +176 -0
  64. package/src/exec.ts +191 -341
  65. package/src/extension.ts +8 -2
  66. package/src/first-paint.ts +26 -9
  67. package/src/form/value.ts +15 -0
  68. package/src/form.tsx +132 -43
  69. package/src/hub/channels.ts +102 -0
  70. package/src/hub/connections.ts +147 -0
  71. package/src/hub/delivery.ts +62 -0
  72. package/src/hub/feed.ts +168 -0
  73. package/src/hub/presence.ts +64 -0
  74. package/src/hub/route.ts +158 -0
  75. package/src/hub/subscriptions.ts +212 -0
  76. package/src/hub/topic.ts +98 -0
  77. package/src/hub/tracked.ts +12 -0
  78. package/src/hub/types.ts +190 -0
  79. package/src/hub/view.ts +147 -0
  80. package/src/hub.ts +132 -758
  81. package/src/index.ts +84 -10
  82. package/src/inline.mjs +356 -0
  83. package/src/inspect/compare.mjs +162 -0
  84. package/src/inspect/routes.mjs +154 -0
  85. package/src/inspect/samples.mjs +59 -0
  86. package/src/inspect/url.mjs +2 -0
  87. package/src/inspect.mjs +146 -187
  88. package/src/link/election.ts +178 -0
  89. package/src/link/lines.ts +27 -0
  90. package/src/link.ts +90 -166
  91. package/src/list-drizzle.ts +104 -18
  92. package/src/list.ts +127 -36
  93. package/src/listen.ts +32 -6
  94. package/src/live/api.ts +18 -0
  95. package/src/live/backoff.ts +6 -0
  96. package/src/live/frames.ts +42 -0
  97. package/src/live/read.ts +77 -0
  98. package/src/live/sub-types.ts +24 -0
  99. package/src/live/transport.ts +86 -0
  100. package/src/live/types.ts +52 -0
  101. package/src/live-fake.ts +20 -5
  102. package/src/live-target.mjs +112 -37
  103. package/src/live.ts +116 -225
  104. package/src/media.ts +8 -1
  105. package/src/migrations.mjs +14 -6
  106. package/src/mux.ts +161 -64
  107. package/src/og/DefaultCard.tsx +40 -9
  108. package/src/og/fonts.mjs +16 -8
  109. package/src/og/images.mjs +43 -22
  110. package/src/og/index.mjs +119 -43
  111. package/src/og/native.mjs +11 -4
  112. package/src/range.mjs +15 -3
  113. package/src/router.ts +56 -6
  114. package/src/routes.mjs +73 -22
  115. package/src/run/admission.ts +80 -0
  116. package/src/run/boot.tsx +115 -0
  117. package/src/run/cache-presentation.ts +67 -0
  118. package/src/run/cache.ts +254 -0
  119. package/src/run/helpers.ts +197 -0
  120. package/src/run/listeners.ts +155 -0
  121. package/src/run/navigation.ts +382 -0
  122. package/src/run/page-skeleton.tsx +35 -0
  123. package/src/run/prefetch.ts +128 -0
  124. package/src/run/retain-view.ts +18 -0
  125. package/src/run/shape-preparation.ts +97 -0
  126. package/src/run/shape-styles.ts +72 -0
  127. package/src/run/source-context/pin.ts +124 -0
  128. package/src/run/source-context.ts +311 -0
  129. package/src/run.tsx +50 -178
  130. package/src/rust/common.ts +163 -0
  131. package/src/rust/native.ts +282 -0
  132. package/src/rust.ts +103 -229
  133. package/src/server/card.tsx +65 -0
  134. package/src/server/compression.ts +115 -0
  135. package/src/server/core.ts +70 -0
  136. package/src/server/dispatch.ts +70 -0
  137. package/src/server/form.ts +85 -0
  138. package/src/server/functions.ts +129 -0
  139. package/src/server/handler.ts +39 -0
  140. package/src/server/head.ts +96 -0
  141. package/src/server/media.ts +60 -0
  142. package/src/server/navigation-cache.ts +80 -0
  143. package/src/server/navigation.tsx +85 -0
  144. package/src/server/page-cold.ts +95 -0
  145. package/src/server/page-stream.tsx +181 -0
  146. package/src/server/page-types.ts +51 -0
  147. package/src/server/page-warm.ts +130 -0
  148. package/src/server/page.ts +69 -0
  149. package/src/server/payload.ts +89 -0
  150. package/src/server/prerender.ts +60 -0
  151. package/src/server/preview.tsx +67 -0
  152. package/src/server/render.ts +167 -0
  153. package/src/server/request.ts +98 -0
  154. package/src/server/resolver.ts +228 -0
  155. package/src/server/routes.ts +150 -0
  156. package/src/server/services.ts +69 -0
  157. package/src/server/text.ts +29 -0
  158. package/src/server/types.ts +160 -0
  159. package/src/server.tsx +82 -971
  160. package/src/shape.mjs +30 -6
  161. package/src/share/display.ts +50 -0
  162. package/src/share/network.ts +119 -0
  163. package/src/share/receive.ts +70 -0
  164. package/src/share/state.ts +30 -0
  165. package/src/share/tab-queue.ts +28 -0
  166. package/src/share-server.ts +128 -43
  167. package/src/share.ts +103 -209
  168. package/src/sheets.mjs +30 -18
  169. package/src/sidecar/build.mjs +170 -0
  170. package/src/sidecar/cargo.mjs +164 -0
  171. package/src/sidecar/common.mjs +17 -0
  172. package/src/sidecar/dev-build.mjs +91 -0
  173. package/src/sidecar/dev-watch.mjs +88 -0
  174. package/src/sidecar/manifest.mjs +19 -0
  175. package/src/sidecar/process.mjs +58 -0
  176. package/src/sidecar/refresh.mjs +19 -0
  177. package/src/sidecar.mjs +75 -416
  178. package/src/socket/check.ts +50 -0
  179. package/src/socket/deliver.ts +16 -0
  180. package/src/socket/publish.ts +56 -0
  181. package/src/socket/rooms.ts +41 -0
  182. package/src/socket/route.ts +79 -0
  183. package/src/socket/shared.ts +26 -0
  184. package/src/socket/types.ts +48 -0
  185. package/src/socket.ts +205 -194
  186. package/src/stream.ts +10 -1
  187. package/src/verify/app.ts +39 -0
  188. package/src/verify/cleanup.ts +27 -0
  189. package/src/verify/database.ts +11 -0
  190. package/src/verify/live.ts +63 -0
  191. package/src/verify/notify.ts +35 -0
  192. package/src/verify/skip.ts +19 -0
  193. package/src/verify/support.ts +149 -0
  194. package/src/verify/timing.ts +15 -0
  195. package/src/verify/trigger.ts +14 -0
  196. package/src/verify.ts +77 -176
  197. package/src/view.tsx +96 -11
  198. package/src/vite/asset-bytes.mjs +95 -0
  199. package/src/vite/assets.mjs +117 -0
  200. package/src/vite/config-hooks.mjs +166 -0
  201. package/src/vite/dev-hooks.mjs +103 -0
  202. package/src/vite/dev.mjs +102 -0
  203. package/src/vite/entries.mjs +145 -0
  204. package/src/vite/module-hooks.mjs +288 -0
  205. package/src/vite/route-modules.mjs +289 -0
  206. package/src/vite/server-functions.mjs +222 -0
  207. package/src/vite/shape-entry-css.mjs +187 -0
  208. package/src/vite/shape-override.mjs +226 -0
  209. package/src/vite/shared.mjs +51 -0
  210. package/src/vite/sheets.mjs +92 -0
  211. package/src/vite/state.mjs +89 -0
  212. package/src/vite/syntax.mjs +139 -0
  213. package/src/vite.mjs +152 -853
  214. package/src/capture.d.mts +0 -1
@@ -0,0 +1,63 @@
1
+ import type { VerifyOptions } from '../data'
2
+ import type { Link } from '../verify'
3
+ import { Broke, read, type subscribe } from './support'
4
+ import type { openApp } from './app'
5
+
6
+ type Timed = <T>(link: Link, run: () => Promise<T>) => Promise<T>
7
+ type Live = Awaited<ReturnType<typeof openApp>>
8
+ type Denied = Awaited<ReturnType<typeof subscribe>> | undefined
9
+
10
+ export async function checkLive(o: {
11
+ live: Live
12
+ denied: Denied
13
+ timed: Timed
14
+ skip: (links: Link[], why: string) => void
15
+ url: string
16
+ id: string
17
+ input: unknown
18
+ cookie: string | undefined
19
+ other: string | undefined
20
+ marker: string
21
+ timeout: number
22
+ options: VerifyOptions
23
+ }) {
24
+ const { live, denied, timed, skip, url, id, input, cookie, other, marker, timeout, options } = o
25
+ await timed('poke', async () => {
26
+ const p = await live.until(
27
+ (f) => f[0] === 'p' && (f[1] as { t: string[] }).t.some((t) => live.tags.includes(t)),
28
+ timeout,
29
+ )
30
+ if (!p)
31
+ throw new Broke(
32
+ 'poke',
33
+ `the database notified, and the app never poked this subscription within ${timeout} ms: its listener, or a tag the query doesn't declare`,
34
+ )
35
+ })
36
+ await timed('refetch', async () => {
37
+ const end = Date.now() + timeout
38
+ for (;;) {
39
+ const res = await read(url, id, input, cookie)
40
+ if (res.ok && (await res.text()).includes(marker)) return
41
+ if (Date.now() > end)
42
+ throw new Broke(
43
+ 'refetch',
44
+ `poked, and the query didn't return the row within ${timeout} ms${options.row ? '' : ': if it filters by user, say whose it is in `verify.row`'}`,
45
+ )
46
+ await new Promise((ok) => {
47
+ setTimeout(ok, 50)
48
+ })
49
+ }
50
+ })
51
+ if (denied)
52
+ await timed('denied', async () => {
53
+ const res = await read(url, id, input, other)
54
+ const text = await res.text()
55
+ if (text.includes(marker) || denied.frames.some((f) => JSON.stringify(f[1]).includes(marker)))
56
+ throw new Broke('denied', `${other ? 'the other user' : 'a signed-out client'} got the row`)
57
+ })
58
+ else
59
+ skip(
60
+ ['denied'],
61
+ 'a query with no `auth` and no other user to compare: set `WEFT_STAGING_OTHER_COOKIE`',
62
+ )
63
+ }
@@ -0,0 +1,35 @@
1
+ import { CHANNEL } from '../changes-sql.mjs'
2
+ import { Broke } from './support'
3
+
4
+ export async function waitNotice(
5
+ heard: string[],
6
+ table: string,
7
+ id: string,
8
+ timeout: number,
9
+ setHearing: (fn: () => void) => void,
10
+ ) {
11
+ const seen = () =>
12
+ heard.some((h) => {
13
+ try {
14
+ const m = JSON.parse(h)
15
+ return m.t === table && (!m.i || m.i.includes(id))
16
+ } catch {
17
+ return false
18
+ }
19
+ })
20
+ if (!seen())
21
+ await new Promise<void>((ok) => {
22
+ const t = setTimeout(ok, timeout)
23
+ setHearing(() => {
24
+ if (seen()) {
25
+ clearTimeout(t)
26
+ ok()
27
+ }
28
+ })
29
+ })
30
+ if (!seen())
31
+ throw new Broke(
32
+ 'notify',
33
+ `the trigger on \`${table}\` sent nothing on \`${CHANNEL}\` within ${timeout} ms`,
34
+ )
35
+ }
@@ -0,0 +1,19 @@
1
+ import type { Link, VerifyResult } from '../verify'
2
+
3
+ export function skipper(untested: VerifyResult['untested']) {
4
+ return (links: Link[], why: string) => {
5
+ for (const link of links) untested.push({ link, why })
6
+ }
7
+ }
8
+
9
+ export function skipWithoutUrl(
10
+ url: string | undefined,
11
+ sql: unknown,
12
+ skip: (links: Link[], why: string) => void,
13
+ ) {
14
+ if (!url)
15
+ skip(
16
+ sql ? ['app', 'poke', 'refetch', 'denied'] : ['app'],
17
+ 'no URL target: the database half was tested, not the running app. Set `WEFT_STAGING_URL` or `verify.url`',
18
+ )
19
+ }
@@ -0,0 +1,149 @@
1
+ import type postgres from 'postgres'
2
+ import type { Link } from '../verify'
3
+
4
+ export class Broke extends Error {
5
+ constructor(
6
+ public link: Link,
7
+ why: string,
8
+ ) {
9
+ super(why)
10
+ }
11
+ }
12
+
13
+ export const clean = (s: unknown) =>
14
+ String((s as Error | null | undefined)?.message ?? s).replace(/\/\/[^@/\s]*@/g, '//…@')
15
+
16
+ /** A value for a required column nobody named. Foreign keys can't be guessed: `row` is for those. */
17
+ function made(type: string, marker: string): unknown {
18
+ if (/uuid/.test(type)) return crypto.randomUUID()
19
+ if (/int|serial/.test(type)) return Math.floor(Math.random() * 2_000_000_000)
20
+ if (/numeric|double|real|money/.test(type)) return 0
21
+ if (/bool/.test(type)) return false
22
+ if (/timestamp|date/.test(type)) return new Date().toISOString()
23
+ if (/time/.test(type)) return '00:00:00'
24
+ if (/json/.test(type)) return JSON.stringify({ marker })
25
+ return marker
26
+ }
27
+
28
+ export async function insert(
29
+ sql: postgres.Sql,
30
+ table: string,
31
+ marker: string,
32
+ row: Record<string, unknown>,
33
+ pk: string[],
34
+ ) {
35
+ const cols =
36
+ await sql`select column_name, data_type, is_nullable, column_default, is_identity, is_generated from information_schema.columns where table_schema = current_schema() and table_name = ${table} order by ordinal_position`
37
+ if (!cols.length) throw new Broke('write', `no table \`${table}\` in this database`)
38
+ const values: Record<string, unknown> = { ...row }
39
+ let marked = false
40
+ for (const c of cols) {
41
+ const name = c.column_name as string
42
+ if (name in values) continue
43
+ if (/char|text/.test(c.data_type) && !marked) {
44
+ values[name] = marker
45
+ marked = true
46
+ } else if (
47
+ c.is_nullable === 'NO' &&
48
+ c.column_default === null &&
49
+ c.is_identity === 'NO' &&
50
+ c.is_generated === 'NEVER'
51
+ )
52
+ values[name] = made(c.data_type, marker)
53
+ }
54
+ if (!marked)
55
+ throw new Broke(
56
+ 'write',
57
+ `\`${table}\` has no free text column to mark a row with: name one less in \`row\``,
58
+ )
59
+ const names = Object.keys(values)
60
+ const quoted = (n: string) => `"${n.replaceAll('"', '""')}"`
61
+ const key = pk.length ? pk : names.slice(0, 1)
62
+ const [got] = await sql.unsafe(
63
+ `insert into ${quoted(table)} (${names.map(quoted).join(', ')}) values (${names.map((_, i) => `$${i + 1}`).join(', ')}) returning ${key.map(quoted).join(', ')}`,
64
+ names.map((n) => values[n]) as never[],
65
+ )
66
+ if (!got) throw new Broke('write', `\`${table}\` took the row and returned nothing`)
67
+ const where = key.map((k, i) => `${quoted(k)} = $${i + 1}`).join(' and ')
68
+ return {
69
+ id: key.map((k) => String(got[k])).join(':'),
70
+ remove: () =>
71
+ sql.unsafe(
72
+ `delete from ${quoted(table)} where ${where}`,
73
+ key.map((k) => got[k]),
74
+ ),
75
+ }
76
+ }
77
+
78
+ export async function primaryKey(sql: postgres.Sql, table: string): Promise<string[]> {
79
+ const rows =
80
+ await sql`select a.attname from pg_index i join pg_class c on c.oid = i.indrelid join pg_attribute a on a.attrelid = c.oid and a.attnum = any(i.indkey) where c.relname = ${table} and i.indisprimary`
81
+ return rows.map((r) => r.attname as string)
82
+ }
83
+
84
+ /** One SSE connection to the app, by the protocol a browser's tab speaks. */
85
+ type Frame = [event: string, data: unknown]
86
+ export type Hello = { l?: { m?: string; d?: unknown } }
87
+
88
+ export async function subscribe(
89
+ url: string,
90
+ id: string,
91
+ input: unknown,
92
+ cookie: string | undefined,
93
+ signal: AbortSignal,
94
+ ) {
95
+ const res = await fetch(`${url}/_weft/rt`, {
96
+ method: 'POST',
97
+ signal,
98
+ headers: {
99
+ 'content-type': 'application/json',
100
+ 'x-weft': '1',
101
+ accept: 'text/event-stream',
102
+ origin: url,
103
+ ...(cookie && { cookie }),
104
+ },
105
+ body: JSON.stringify({ s: [[1, 'q', id, input]] }),
106
+ })
107
+ if (!res.ok) throw new Error(`\`/_weft/rt\` answered ${res.status}`)
108
+ const frames: Frame[] = []
109
+ const waiting = new Set<() => void>()
110
+ void (async () => {
111
+ const dec = new TextDecoder()
112
+ let buf = ''
113
+ try {
114
+ for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
115
+ buf += dec.decode(chunk, { stream: true })
116
+ for (let i; (i = buf.indexOf('\n\n')) >= 0; buf = buf.slice(i + 2)) {
117
+ if (buf[0] === ':') continue
118
+ const line = buf.indexOf('\n')
119
+ frames.push([buf.slice(7, line), JSON.parse(buf.slice(line + 7, i))])
120
+ for (const w of waiting) w()
121
+ }
122
+ }
123
+ } catch {}
124
+ })()
125
+ const until = async (test: (f: Frame) => boolean, ms: number) => {
126
+ const end = Date.now() + ms
127
+ for (;;) {
128
+ const hit = frames.find(test)
129
+ if (hit) return hit
130
+ const left = end - Date.now()
131
+ if (left <= 0) return undefined
132
+ await new Promise<void>((ok) => {
133
+ const t = setTimeout(ok, left)
134
+ waiting.add(() => {
135
+ clearTimeout(t)
136
+ ok()
137
+ })
138
+ })
139
+ waiting.clear()
140
+ }
141
+ }
142
+ return { frames, until }
143
+ }
144
+
145
+ export const read = (url: string, id: string, input: unknown, cookie?: string) =>
146
+ fetch(
147
+ `${url}/_weft/fn/${id}${input == null ? '' : `?i=${encodeURIComponent(JSON.stringify(input))}`}`,
148
+ { headers: { accept: 'application/json', ...(cookie && { cookie }) } },
149
+ )
@@ -0,0 +1,15 @@
1
+ import type { Link, VerifyResult } from '../verify'
2
+ import { Broke, clean } from './support'
3
+
4
+ export function timer(ms: VerifyResult['ms']) {
5
+ return async <T>(link: Link, run: () => Promise<T>): Promise<T> => {
6
+ const t = performance.now()
7
+ try {
8
+ return await run()
9
+ } catch (e) {
10
+ throw e instanceof Broke ? e : new Broke(link, clean(e))
11
+ } finally {
12
+ ms[link] = Math.round(performance.now() - t)
13
+ }
14
+ }
15
+ }
@@ -0,0 +1,14 @@
1
+ import type postgres from 'postgres'
2
+ import { Broke } from './support'
3
+
4
+ export async function checkTrigger(sql: postgres.Sql, table: string) {
5
+ const [t] =
6
+ await sql`select t.tgenabled from pg_trigger t join pg_class c on c.oid = t.tgrelid where t.tgname = 'weft_changes_ins' and c.relname = ${table}`
7
+ if (!t)
8
+ throw new Broke(
9
+ 'trigger',
10
+ `\`${table}\` has no change trigger: run \`weft db live\`, and apply the migration there`,
11
+ )
12
+ if (t.tgenabled === 'D')
13
+ throw new Broke('trigger', `the change trigger on \`${table}\` is disabled`)
14
+ }
package/src/verify.ts CHANGED
@@ -7,15 +7,32 @@
7
7
  // A database target tests the first half and a URL target the app's half; with both, the whole chain. Whatever wasn't
8
8
  // tested is reported, by name. `live-target.mjs` decides whether a target may be touched at all.
9
9
 
10
- import postgres from 'postgres'
11
10
  import { CHANNEL } from './changes-sql.mjs'
12
11
  import type { Context, Fn, VerifyOptions } from './data'
13
12
  import type { Source } from './changes'
13
+ import { Broke, clean, insert, primaryKey, subscribe } from './verify/support'
14
+ import { waitNotice } from './verify/notify'
15
+ import { database } from './verify/database'
16
+ import { skipper, skipWithoutUrl } from './verify/skip'
17
+ import { cleanup } from './verify/cleanup'
18
+ import { timer } from './verify/timing'
19
+ import { checkTrigger } from './verify/trigger'
20
+ import { checkLive } from './verify/live'
21
+ import { openApp } from './verify/app'
14
22
 
15
23
  export type { VerifyOptions }
16
24
 
17
25
  /** Each step of the round trip, in order. */
18
- export const LINKS = ['trigger', 'write', 'notify', 'app', 'poke', 'refetch', 'denied', 'cleanup'] as const
26
+ export const LINKS = [
27
+ 'trigger',
28
+ 'write',
29
+ 'notify',
30
+ 'app',
31
+ 'poke',
32
+ 'refetch',
33
+ 'denied',
34
+ 'cleanup',
35
+ ] as const
19
36
  /** One step of the round trip. */
20
37
  export type Link = (typeof LINKS)[number]
21
38
 
@@ -28,96 +45,6 @@ export interface VerifyResult {
28
45
  untested: { link: Link; why: string }[]
29
46
  }
30
47
 
31
- class Broke extends Error {
32
- constructor(public link: Link, why: string) { super(why) }
33
- }
34
-
35
- const clean = (s: unknown) => String((s as Error | null | undefined)?.message ?? s).replace(/\/\/[^@/\s]*@/g, '//…@')
36
-
37
- /** A value for a required column nobody named. Foreign keys can't be guessed: `row` is for those. */
38
- function made(type: string, marker: string): unknown {
39
- if (/uuid/.test(type)) return crypto.randomUUID()
40
- if (/int|serial/.test(type)) return Math.floor(Math.random() * 2_000_000_000)
41
- if (/numeric|double|real|money/.test(type)) return 0
42
- if (/bool/.test(type)) return false
43
- if (/timestamp|date/.test(type)) return new Date().toISOString()
44
- if (/time/.test(type)) return '00:00:00'
45
- if (/json/.test(type)) return JSON.stringify({ marker })
46
- return marker
47
- }
48
-
49
- async function insert(sql: postgres.Sql, table: string, marker: string, row: Record<string, unknown>, pk: string[]) {
50
- const cols = await sql`select column_name, data_type, is_nullable, column_default, is_identity, is_generated from information_schema.columns where table_schema = current_schema() and table_name = ${table} order by ordinal_position`
51
- if (!cols.length) throw new Broke('write', `no table \`${table}\` in this database`)
52
- const values: Record<string, unknown> = { ...row }
53
- let marked = false
54
- for (const c of cols) {
55
- const name = c.column_name as string
56
- if (name in values) continue
57
- if (/char|text/.test(c.data_type) && !marked) {
58
- values[name] = marker
59
- marked = true
60
- } else if (c.is_nullable === 'NO' && c.column_default === null && c.is_identity === 'NO' && c.is_generated === 'NEVER') values[name] = made(c.data_type, marker)
61
- }
62
- if (!marked) throw new Broke('write', `\`${table}\` has no free text column to mark a row with: name one less in \`row\``)
63
- const names = Object.keys(values)
64
- const quoted = (n: string) => `"${n.replaceAll('"', '""')}"`
65
- const key = pk.length ? pk : names.slice(0, 1)
66
- const [got] = await sql.unsafe(`insert into ${quoted(table)} (${names.map(quoted).join(', ')}) values (${names.map((_, i) => `$${i + 1}`).join(', ')}) returning ${key.map(quoted).join(', ')}`, names.map(n => values[n]) as never[])
67
- if (!got) throw new Broke('write', `\`${table}\` took the row and returned nothing`)
68
- const where = key.map((k, i) => `${quoted(k)} = $${i + 1}`).join(' and ')
69
- return { id: key.map(k => String(got[k])).join(':'), remove: () => sql.unsafe(`delete from ${quoted(table)} where ${where}`, key.map(k => got[k])) }
70
- }
71
-
72
- async function primaryKey(sql: postgres.Sql, table: string): Promise<string[]> {
73
- const rows = await sql`select a.attname from pg_index i join pg_class c on c.oid = i.indrelid join pg_attribute a on a.attrelid = c.oid and a.attnum = any(i.indkey) where c.relname = ${table} and i.indisprimary`
74
- return rows.map(r => r.attname as string)
75
- }
76
-
77
- /** One SSE connection to the app, by the protocol a browser's tab speaks. */
78
- type Frame = [event: string, data: unknown]
79
- type Hello = { l?: { m?: string; d?: unknown } }
80
-
81
- async function subscribe(url: string, id: string, input: unknown, cookie: string | undefined, signal: AbortSignal) {
82
- const res = await fetch(`${url}/_weft/rt`, { method: 'POST', signal, headers: { 'content-type': 'application/json', 'x-weft': '1', accept: 'text/event-stream', origin: url, ...(cookie && { cookie }) }, body: JSON.stringify({ s: [[1, 'q', id, input]] }) })
83
- if (!res.ok) throw new Error(`\`/_weft/rt\` answered ${res.status}`)
84
- const frames: Frame[] = []
85
- const waiting = new Set<() => void>()
86
- void (async () => {
87
- const dec = new TextDecoder()
88
- let buf = ''
89
- try {
90
- for await (const chunk of res.body as unknown as AsyncIterable<Uint8Array>) {
91
- buf += dec.decode(chunk, { stream: true })
92
- for (let i; (i = buf.indexOf('\n\n')) >= 0; buf = buf.slice(i + 2)) {
93
- if (buf[0] === ':') continue
94
- const line = buf.indexOf('\n')
95
- frames.push([buf.slice(7, line), JSON.parse(buf.slice(line + 7, i))])
96
- for (const w of waiting) w()
97
- }
98
- }
99
- } catch {}
100
- })()
101
- const until = async (test: (f: Frame) => boolean, ms: number) => {
102
- const end = Date.now() + ms
103
- for (;;) {
104
- const hit = frames.find(test)
105
- if (hit) return hit
106
- const left = end - Date.now()
107
- if (left <= 0) return undefined
108
- await new Promise<void>(ok => {
109
- const t = setTimeout(ok, left)
110
- waiting.add(() => { clearTimeout(t); ok() })
111
- })
112
- waiting.clear()
113
- }
114
- }
115
- return { frames, until }
116
- }
117
-
118
- const read = (url: string, id: string, input: unknown, cookie?: string) =>
119
- fetch(`${url}/_weft/fn/${id}${input == null ? '' : `?i=${encodeURIComponent(JSON.stringify(input))}`}`, { headers: { accept: 'application/json', ...(cookie && { cookie }) } })
120
-
121
48
  /** What `roundTrip` needs: the query, the feed, and where the app is. */
122
49
  export interface RoundTrip {
123
50
  fn: Fn
@@ -132,120 +59,94 @@ export interface RoundTrip {
132
59
  function tableOf(fn: Fn, source: Source | undefined, input: unknown): string | undefined {
133
60
  const any = new Proxy({}, { get: (_, k) => (k === Symbol.toPrimitive ? () => '*' : '*') })
134
61
  let tags: readonly string[] = []
135
- try { tags = typeof fn.def.tags === 'function' ? fn.def.tags(input ?? any, any as Context) : fn.def.tags ?? [] } catch {}
136
- const names = tags.map(t => t.split(':')[0])
137
- return names.find(t => t !== undefined && source?.tables.includes(t)) ?? names[0]
62
+ try {
63
+ tags =
64
+ typeof fn.def.tags === 'function'
65
+ ? fn.def.tags(input ?? any, any as Context)
66
+ : (fn.def.tags ?? [])
67
+ } catch {}
68
+ const names = tags.map((t) => t.split(':')[0])
69
+ return names.find((t) => t !== undefined && source?.tables.includes(t)) ?? names[0]
138
70
  }
139
71
 
140
72
  /** Writes a real row and follows it to a live client, naming the first link that breaks. */
141
- export async function roundTrip({ fn, source, url, db, options, env }: RoundTrip): Promise<VerifyResult> {
142
- const timeout = options.timeout ?? 5000
73
+ export async function roundTrip({
74
+ fn,
75
+ source,
76
+ url,
77
+ db,
78
+ options,
79
+ env,
80
+ }: RoundTrip): Promise<VerifyResult> {
81
+ const { input, timeout = 5000 } = options
143
82
  const marker = `weft-verify-${crypto.randomUUID()}` // concurrent builds never touch each other's row
144
83
  const ms: VerifyResult['ms'] = {}
145
84
  const untested: VerifyResult['untested'] = []
146
- const input = options.input
147
85
  const cookie = env[options.cookie ?? 'WEFT_STAGING_COOKIE']
148
86
  const other = env[options.otherCookie ?? 'WEFT_STAGING_OTHER_COOKIE']
149
- const timed = async <T,>(link: Link, run: () => Promise<T>): Promise<T> => {
150
- const t = performance.now()
151
- try {
152
- return await run()
153
- } catch (e) {
154
- throw e instanceof Broke ? e : new Broke(link, clean(e))
155
- } finally {
156
- ms[link] = Math.round(performance.now() - t)
157
- }
158
- }
159
- const skip = (links: Link[], why: string) => { for (const link of links) untested.push({ link, why }) }
160
- const sql = db ? postgres(db, { max: 3, idle_timeout: 2, connect_timeout: 5, onnotice: () => {} }) : undefined
161
- const listener = db ? postgres(db, { max: 1, idle_timeout: 2, connect_timeout: 5, onnotice: () => {} }) : undefined
87
+ const timed = timer(ms)
88
+ const skip = skipper(untested)
89
+ const { sql, listener } = database(db)
162
90
  const ac = new AbortController()
163
91
  let row: Awaited<ReturnType<typeof insert>> | undefined
164
- let cleaning: unknown
165
92
  try {
166
93
  const table = tableOf(fn, source, input)
167
94
  if (!table) throw new Broke('trigger', `\`${fn.$id}\` has no tag naming a table`)
168
95
  const heard: string[] = []
169
96
  let hearing: (() => void) | undefined
170
97
  if (sql) {
171
- await timed('trigger', async () => {
172
- const [t] = await sql`select t.tgenabled from pg_trigger t join pg_class c on c.oid = t.tgrelid where t.tgname = 'weft_changes_ins' and c.relname = ${table}`
173
- if (!t) throw new Broke('trigger', `\`${table}\` has no change trigger: run \`weft db live\`, and apply the migration there`)
174
- if (t.tgenabled === 'D') throw new Broke('trigger', `the change trigger on \`${table}\` is disabled`)
98
+ await timed('trigger', () => checkTrigger(sql, table))
99
+ await listener!.listen(CHANNEL, (p) => {
100
+ heard.push(p)
101
+ hearing?.()
175
102
  })
176
- await listener!.listen(CHANNEL, p => { heard.push(p); hearing?.() })
177
- } else skip(['trigger', 'write', 'notify', 'poke', 'refetch', 'denied', 'cleanup'], 'no database target: nothing wrote a row, so only the app and its feed were checked. Set `STAGING_DATABASE_URL` or `verify.db`')
178
-
179
- const live = url ? await timed('app', async () => {
180
- const a = await subscribe(url, fn.$id, input, cookie, ac.signal)
181
- const hi = await a.until(f => f[0] === 'h', timeout)
182
- if (!hi) throw new Broke('app', 'the app accepted the connection and said nothing')
183
- const said = (hi[1] as Hello).l
184
- if (said?.m !== 'feed' && said?.m !== 'external') throw new Broke('app', `the app's live mode is \`${said?.m ?? 'app'}\`, not a feed: its log says why`)
185
- if (said?.d) throw new Broke('app', 'the app\'s change feed is down')
186
- const ack = await a.until(f => (f[0] === 'a' || f[0] === 'e') && (f[1] as unknown[])[0] === 1, timeout)
187
- if (!ack) throw new Broke('app', 'no answer to the subscription')
188
- const answer = (ack[1] as [number, unknown])[1]
189
- const failed = answer as { code?: string; message?: string }
190
- if (ack[0] === 'e') throw new Broke('app', failed.code === 'Q009' ? `the app doesn't know \`${fn.$id}\`: it runs another build` : failed.code === 'A002' ? `\`${fn.$id}\` needs a signed-in user: set \`WEFT_STAGING_COOKIE\`` : `the subscription failed: ${failed.message}`)
191
- return { ...a, tags: answer as string[] }
192
- }) : undefined
193
- if (!url) skip(sql ? ['app', 'poke', 'refetch', 'denied'] : ['app'], 'no URL target: the database half was tested, not the running app. Set `WEFT_STAGING_URL` or `verify.url`')
103
+ } else
104
+ skip(
105
+ ['trigger', 'write', 'notify', 'poke', 'refetch', 'denied', 'cleanup'],
106
+ 'no database target: nothing wrote a row, so only the app and its feed were checked. Set `STAGING_DATABASE_URL` or `verify.db`',
107
+ )
108
+
109
+ const live = url
110
+ ? await timed('app', () =>
111
+ openApp({ url, id: fn.$id, input, cookie, signal: ac.signal, timeout }),
112
+ )
113
+ : undefined
114
+ skipWithoutUrl(url, sql, skip)
194
115
 
195
116
  if (!sql) {
196
117
  if (live) ac.abort()
197
118
  return { ok: true, ms, untested }
198
119
  }
199
- const denied = url && !(cookie && !other && !fn.def.auth) ? await subscribe(url, fn.$id, input, other, ac.signal).catch(() => undefined) : undefined
120
+ const denied =
121
+ url && !(cookie && !other && !fn.def.auth)
122
+ ? await subscribe(url, fn.$id, input, other, ac.signal).catch(() => undefined)
123
+ : undefined
200
124
 
201
125
  const pk = await primaryKey(sql, table)
202
126
  row = await timed('write', () => insert(sql, table, marker, options.row ?? {}, pk))
203
- await timed('notify', async () => {
204
- const seen = () => heard.some(h => { try { const m = JSON.parse(h); return m.t === table && (!m.i || m.i.includes(row!.id)) } catch { return false } })
205
- if (!seen()) await new Promise<void>(ok => {
206
- const t = setTimeout(ok, timeout)
207
- hearing = () => { if (seen()) { clearTimeout(t); ok() } }
208
- })
209
- if (!seen()) throw new Broke('notify', `the trigger on \`${table}\` sent nothing on \`${CHANNEL}\` within ${timeout} ms`)
210
- })
211
- if (live) {
212
- await timed('poke', async () => {
213
- const p = await live.until(f => f[0] === 'p' && (f[1] as { t: string[] }).t.some(t => live.tags.includes(t)), timeout)
214
- if (!p) throw new Broke('poke', `the database notified, and the app never poked this subscription within ${timeout} ms: its listener, or a tag the query doesn't declare`)
215
- })
216
- await timed('refetch', async () => {
217
- const end = Date.now() + timeout
218
- for (;;) {
219
- const res = await read(url!, fn.$id, input, cookie)
220
- if (res.ok && (await res.text()).includes(marker)) return
221
- if (Date.now() > end) throw new Broke('refetch', `poked, and the query didn't return the row within ${timeout} ms${options.row ? '' : ': if it filters by user, say whose it is in `verify.row`'}`)
222
- await new Promise(ok => { setTimeout(ok, 50) })
223
- }
127
+ await timed('notify', () =>
128
+ waitNotice(heard, table, row!.id, timeout, (wake) => (hearing = wake)),
129
+ )
130
+ if (live)
131
+ await checkLive({
132
+ live,
133
+ denied,
134
+ timed,
135
+ skip,
136
+ url: url!,
137
+ id: fn.$id,
138
+ input,
139
+ cookie,
140
+ other,
141
+ marker,
142
+ timeout,
143
+ options,
224
144
  })
225
- if (denied) await timed('denied', async () => {
226
- const res = await read(url!, fn.$id, input, other)
227
- const text = await res.text()
228
- if (text.includes(marker) || denied.frames.some(f => JSON.stringify(f[1]).includes(marker))) throw new Broke('denied', `${other ? 'the other user' : 'a signed-out client'} got the row`)
229
- })
230
- else skip(['denied'], 'a query with no `auth` and no other user to compare: set `WEFT_STAGING_OTHER_COOKIE`')
231
- }
232
145
  return { ok: true, ms, untested }
233
146
  } catch (e) {
234
147
  const b = e instanceof Broke ? e : new Broke('trigger', clean(e))
235
148
  return { ok: false, broke: { link: b.link, why: clean(b.message) }, ms, untested }
236
149
  } finally {
237
- ac.abort()
238
- if (row) {
239
- const t = performance.now()
240
- try {
241
- await row.remove()
242
- ms.cleanup = Math.round(performance.now() - t)
243
- } catch (e) {
244
- cleaning = e
245
- }
246
- }
247
- await Promise.all([sql?.end({ timeout: 1 }), listener?.end({ timeout: 1 })]).catch(() => {})
248
- // oxlint-disable-next-line no-unsafe-finally -- a row left in a real database outranks whatever the run concluded
249
- if (cleaning) throw new Broke('cleanup', `row \`${marker}\` is still there: ${clean(cleaning)}`)
150
+ await cleanup({ ac, row, ms, sql, listener, marker })
250
151
  }
251
152
  }