@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.
- package/README.md +1278 -12
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -0
- package/crate/README.md +322 -0
- package/crate/rust-toolchain.toml +3 -0
- package/crate/src/build/emit.rs +103 -0
- package/crate/src/build.rs +367 -0
- package/crate/src/ctx.rs +186 -0
- package/crate/src/envelope.rs +163 -0
- package/crate/src/functions.rs +173 -0
- package/crate/src/lib.rs +118 -0
- package/crate/src/native.rs +212 -0
- package/crate/src/otel.rs +62 -0
- package/crate/src/server.rs +203 -0
- package/package.json +46 -37
- package/src/app.mjs +38 -8
- package/src/asset-receipt.d.mts +13 -0
- package/src/asset-receipt.mjs +61 -0
- package/src/auth/better-auth.ts +215 -17
- package/src/auth/drizzle.ts +1 -1
- package/src/auth.ts +21 -6
- package/src/boot.ts +19 -8
- package/src/bus/postgres.ts +32 -19
- package/src/bus.ts +7 -4
- package/src/{capture.mjs → capture.ts} +34 -1
- package/src/changes-sql.d.mts +8 -2
- package/src/changes-sql.mjs +26 -10
- package/src/changes.ts +165 -66
- package/src/channel/connections.ts +216 -0
- package/src/channel/socket.ts +396 -0
- package/src/channel-fake.ts +19 -4
- package/src/channel.ts +120 -492
- package/src/client.tsx +9 -2
- package/src/contract/describe.mjs +231 -0
- package/src/contract.mjs +144 -157
- package/src/data/cache.ts +354 -0
- package/src/data/definitions.ts +94 -0
- package/src/data/draft.ts +104 -0
- package/src/data/foundation.ts +95 -0
- package/src/data/live-api.ts +22 -0
- package/src/data/mutation.ts +138 -0
- package/src/data/primitives.ts +135 -0
- package/src/data/query.ts +106 -0
- package/src/data/transport.ts +121 -0
- package/src/data/types.ts +303 -0
- package/src/data.ts +69 -894
- package/src/db/bun-sqlite.d.ts +4 -1
- package/src/db/changes-mysql.ts +61 -21
- package/src/db/changes-postgres.ts +197 -118
- package/src/db/changes-sqlite.ts +131 -64
- package/src/db/core.ts +38 -13
- package/src/db/index.ts +20 -4
- package/src/db/mysql.ts +22 -6
- package/src/db/postgres.ts +49 -13
- package/src/db/sqlite.ts +53 -27
- package/src/derive.d.mts +4 -1
- package/src/derive.mjs +136 -39
- package/src/dev-runtime.mjs +8 -4
- package/src/exec/errors.ts +57 -0
- package/src/exec/landing.ts +66 -0
- package/src/exec/scope.ts +174 -0
- package/src/exec/touch.ts +80 -0
- package/src/exec/validation.ts +176 -0
- package/src/exec.ts +191 -341
- package/src/extension.ts +8 -2
- package/src/first-paint.ts +26 -9
- package/src/form/value.ts +15 -0
- package/src/form.tsx +132 -43
- package/src/hub/channels.ts +102 -0
- package/src/hub/connections.ts +147 -0
- package/src/hub/delivery.ts +62 -0
- package/src/hub/feed.ts +168 -0
- package/src/hub/presence.ts +64 -0
- package/src/hub/route.ts +158 -0
- package/src/hub/subscriptions.ts +212 -0
- package/src/hub/topic.ts +98 -0
- package/src/hub/tracked.ts +12 -0
- package/src/hub/types.ts +190 -0
- package/src/hub/view.ts +147 -0
- package/src/hub.ts +132 -758
- package/src/index.ts +84 -10
- package/src/inline.mjs +356 -0
- package/src/inspect/compare.mjs +162 -0
- package/src/inspect/routes.mjs +154 -0
- package/src/inspect/samples.mjs +59 -0
- package/src/inspect/url.mjs +2 -0
- package/src/inspect.mjs +146 -187
- package/src/link/election.ts +178 -0
- package/src/link/lines.ts +27 -0
- package/src/link.ts +90 -166
- package/src/list-drizzle.ts +104 -18
- package/src/list.ts +127 -36
- package/src/listen.ts +32 -6
- package/src/live/api.ts +18 -0
- package/src/live/backoff.ts +6 -0
- package/src/live/frames.ts +42 -0
- package/src/live/read.ts +77 -0
- package/src/live/sub-types.ts +24 -0
- package/src/live/transport.ts +86 -0
- package/src/live/types.ts +52 -0
- package/src/live-fake.ts +20 -5
- package/src/live-target.mjs +112 -37
- package/src/live.ts +116 -225
- package/src/media.ts +8 -1
- package/src/migrations.mjs +14 -6
- package/src/mux.ts +161 -64
- package/src/og/DefaultCard.tsx +40 -9
- package/src/og/fonts.mjs +16 -8
- package/src/og/images.mjs +43 -22
- package/src/og/index.mjs +119 -43
- package/src/og/native.mjs +11 -4
- package/src/range.mjs +15 -3
- package/src/router.ts +56 -6
- package/src/routes.mjs +73 -22
- package/src/run/admission.ts +80 -0
- package/src/run/boot.tsx +115 -0
- package/src/run/cache-presentation.ts +67 -0
- package/src/run/cache.ts +254 -0
- package/src/run/helpers.ts +197 -0
- package/src/run/listeners.ts +155 -0
- package/src/run/navigation.ts +382 -0
- package/src/run/page-skeleton.tsx +35 -0
- package/src/run/prefetch.ts +128 -0
- package/src/run/retain-view.ts +18 -0
- package/src/run/shape-preparation.ts +97 -0
- package/src/run/shape-styles.ts +72 -0
- package/src/run/source-context/pin.ts +124 -0
- package/src/run/source-context.ts +311 -0
- package/src/run.tsx +50 -178
- package/src/rust/common.ts +163 -0
- package/src/rust/native.ts +282 -0
- package/src/rust.ts +103 -229
- package/src/server/card.tsx +65 -0
- package/src/server/compression.ts +115 -0
- package/src/server/core.ts +70 -0
- package/src/server/dispatch.ts +70 -0
- package/src/server/form.ts +85 -0
- package/src/server/functions.ts +129 -0
- package/src/server/handler.ts +39 -0
- package/src/server/head.ts +96 -0
- package/src/server/media.ts +60 -0
- package/src/server/navigation-cache.ts +80 -0
- package/src/server/navigation.tsx +85 -0
- package/src/server/page-cold.ts +95 -0
- package/src/server/page-stream.tsx +181 -0
- package/src/server/page-types.ts +51 -0
- package/src/server/page-warm.ts +130 -0
- package/src/server/page.ts +69 -0
- package/src/server/payload.ts +89 -0
- package/src/server/prerender.ts +60 -0
- package/src/server/preview.tsx +67 -0
- package/src/server/render.ts +167 -0
- package/src/server/request.ts +98 -0
- package/src/server/resolver.ts +228 -0
- package/src/server/routes.ts +150 -0
- package/src/server/services.ts +69 -0
- package/src/server/text.ts +29 -0
- package/src/server/types.ts +160 -0
- package/src/server.tsx +82 -971
- package/src/shape.mjs +30 -6
- package/src/share/display.ts +50 -0
- package/src/share/network.ts +119 -0
- package/src/share/receive.ts +70 -0
- package/src/share/state.ts +30 -0
- package/src/share/tab-queue.ts +28 -0
- package/src/share-server.ts +128 -43
- package/src/share.ts +103 -209
- package/src/sheets.mjs +30 -18
- package/src/sidecar/build.mjs +170 -0
- package/src/sidecar/cargo.mjs +164 -0
- package/src/sidecar/common.mjs +17 -0
- package/src/sidecar/dev-build.mjs +91 -0
- package/src/sidecar/dev-watch.mjs +88 -0
- package/src/sidecar/manifest.mjs +19 -0
- package/src/sidecar/process.mjs +58 -0
- package/src/sidecar/refresh.mjs +19 -0
- package/src/sidecar.mjs +75 -416
- package/src/socket/check.ts +50 -0
- package/src/socket/deliver.ts +16 -0
- package/src/socket/publish.ts +56 -0
- package/src/socket/rooms.ts +41 -0
- package/src/socket/route.ts +79 -0
- package/src/socket/shared.ts +26 -0
- package/src/socket/types.ts +48 -0
- package/src/socket.ts +205 -194
- package/src/stream.ts +10 -1
- package/src/verify/app.ts +39 -0
- package/src/verify/cleanup.ts +27 -0
- package/src/verify/database.ts +11 -0
- package/src/verify/live.ts +63 -0
- package/src/verify/notify.ts +35 -0
- package/src/verify/skip.ts +19 -0
- package/src/verify/support.ts +149 -0
- package/src/verify/timing.ts +15 -0
- package/src/verify/trigger.ts +14 -0
- package/src/verify.ts +77 -176
- package/src/view.tsx +96 -11
- package/src/vite/asset-bytes.mjs +95 -0
- package/src/vite/assets.mjs +117 -0
- package/src/vite/config-hooks.mjs +166 -0
- package/src/vite/dev-hooks.mjs +103 -0
- package/src/vite/dev.mjs +102 -0
- package/src/vite/entries.mjs +145 -0
- package/src/vite/module-hooks.mjs +288 -0
- package/src/vite/route-modules.mjs +289 -0
- package/src/vite/server-functions.mjs +222 -0
- package/src/vite/shape-entry-css.mjs +187 -0
- package/src/vite/shape-override.mjs +226 -0
- package/src/vite/shared.mjs +51 -0
- package/src/vite/sheets.mjs +92 -0
- package/src/vite/state.mjs +89 -0
- package/src/vite/syntax.mjs +139 -0
- package/src/vite.mjs +152 -853
- 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 = [
|
|
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 {
|
|
136
|
-
|
|
137
|
-
|
|
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({
|
|
142
|
-
|
|
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 =
|
|
150
|
-
|
|
151
|
-
|
|
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',
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
98
|
+
await timed('trigger', () => checkTrigger(sql, table))
|
|
99
|
+
await listener!.listen(CHANNEL, (p) => {
|
|
100
|
+
heard.push(p)
|
|
101
|
+
hearing?.()
|
|
175
102
|
})
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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 =
|
|
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',
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
|
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
|
}
|