@weft-js/start 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +36 -3
- package/crate/Cargo.lock +1347 -0
- package/crate/Cargo.toml +72 -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 +51 -10
- 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 +124 -490
- package/src/client.tsx +9 -2
- package/src/contract/describe.mjs +231 -0
- package/src/contract.mjs +146 -155
- 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 +97 -168
- 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 +113 -35
- 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 -174
- 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 -964
- 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
package/src/db/bun-sqlite.d.ts
CHANGED
|
@@ -3,7 +3,10 @@ declare module 'bun:sqlite' {
|
|
|
3
3
|
/** One open database file. */
|
|
4
4
|
export class Database {
|
|
5
5
|
constructor(path: string, options: { create: boolean })
|
|
6
|
-
prepare(text: string): {
|
|
6
|
+
prepare(text: string): {
|
|
7
|
+
run(...params: unknown[]): unknown
|
|
8
|
+
values(...params: unknown[]): unknown[][]
|
|
9
|
+
}
|
|
7
10
|
run(text: string): unknown
|
|
8
11
|
close(): void
|
|
9
12
|
}
|
package/src/db/changes-mysql.ts
CHANGED
|
@@ -10,45 +10,82 @@ const BEHIND = 512
|
|
|
10
10
|
const KEEP_HOURS = 1
|
|
11
11
|
const PRUNE = 60_000
|
|
12
12
|
|
|
13
|
-
interface Options {
|
|
13
|
+
interface Options {
|
|
14
|
+
pool: Pool
|
|
15
|
+
tables: string[]
|
|
16
|
+
interval?: number
|
|
17
|
+
}
|
|
14
18
|
|
|
15
19
|
/** A bigint column: the driver's number, or its string once past 2^53. */
|
|
16
20
|
type Seq = number | string
|
|
17
21
|
const said = (e: unknown) => (e as Error | null)?.message ?? String(e)
|
|
18
22
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
23
|
+
async function probeMysql(pool: Pool, tables: string[], interval: number): Promise<Probe> {
|
|
24
|
+
try {
|
|
25
|
+
const [rows] = await pool.query(
|
|
26
|
+
`select event_object_table as t from information_schema.triggers where trigger_schema = database() and trigger_name like 'weft\\_changes\\_%\\_upd'`,
|
|
27
|
+
)
|
|
28
|
+
const have = (rows as { t: string }[]).map((r) => r.t)
|
|
29
|
+
const missing = tables.filter((t) => !have.includes(t))
|
|
30
|
+
if (!missing.length)
|
|
31
|
+
return {
|
|
32
|
+
mode: 'feed',
|
|
33
|
+
via: 'mysql-log',
|
|
34
|
+
why: `read every ${interval} ms: MySQL can't push`,
|
|
35
|
+
}
|
|
36
|
+
const [grants] = await pool.query('show grants').catch(() => [[]])
|
|
37
|
+
const may = (grants as Record<string, string>[]).some((g) =>
|
|
38
|
+
/ALL PRIVILEGES|\bTRIGGER\b/.test(Object.values(g)[0] ?? ''),
|
|
39
|
+
)
|
|
40
|
+
return {
|
|
41
|
+
mode: 'off',
|
|
42
|
+
via: 'none',
|
|
43
|
+
why: `no trigger on ${missing.map((t) => `\`${t}\``).join(', ')}, and MySQL has no counter to fall back on`,
|
|
44
|
+
fix: may
|
|
45
|
+
? 'run `weft db live`, then apply the migration'
|
|
46
|
+
: 'run `weft db live`, and apply the migration as a user with the TRIGGER privilege',
|
|
47
|
+
}
|
|
48
|
+
} catch (e) {
|
|
49
|
+
return {
|
|
50
|
+
mode: 'off',
|
|
51
|
+
via: 'none',
|
|
52
|
+
why: `the database didn't answer: ${said(e)}`,
|
|
53
|
+
fix: 'check `DATABASE_URL`',
|
|
31
54
|
}
|
|
32
55
|
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function mysqlChanges({ pool, tables, interval = 500 }: Options) {
|
|
59
|
+
const probe = () => probeMysql(pool, tables, interval)
|
|
33
60
|
|
|
34
61
|
async function start(sink: Sink, how: Probe) {
|
|
35
62
|
if (how.mode === 'off') return () => {}
|
|
36
|
-
let stopped = false,
|
|
37
|
-
|
|
63
|
+
let stopped = false,
|
|
64
|
+
timer: ReturnType<typeof setTimeout> | undefined,
|
|
65
|
+
failed = false,
|
|
66
|
+
pruned = Date.now()
|
|
67
|
+
const [[{ top }]] = (await pool.query(
|
|
68
|
+
`select coalesce(max(seq), 0) as top from ${LOG}`,
|
|
69
|
+
)) as unknown as [[{ top: number | string }]]
|
|
38
70
|
let mark = Number(top)
|
|
39
71
|
// What's already there is old news, and needs no poke.
|
|
40
|
-
const [old] = (await pool.query(`select seq from ${LOG} where seq > ?`, [
|
|
41
|
-
|
|
72
|
+
const [old] = (await pool.query(`select seq from ${LOG} where seq > ?`, [
|
|
73
|
+
Math.max(0, mark - BEHIND),
|
|
74
|
+
])) as unknown as [{ seq: Seq }[]]
|
|
75
|
+
const seen = new Set<number>(old.map((r) => Number(r.seq)))
|
|
42
76
|
const look = async () => {
|
|
43
77
|
if (stopped) return
|
|
44
78
|
try {
|
|
45
|
-
const [rows] = (await pool.query(
|
|
79
|
+
const [rows] = (await pool.query(
|
|
80
|
+
`select seq, tbl, pk from ${LOG} where seq > ? order by seq limit 5000`,
|
|
81
|
+
[Math.max(0, mark - BEHIND)],
|
|
82
|
+
)) as unknown as [{ seq: Seq; tbl: string; pk: string | null }[]]
|
|
46
83
|
if (failed) {
|
|
47
84
|
failed = false
|
|
48
85
|
sink.lost()
|
|
49
86
|
sink.up()
|
|
50
87
|
}
|
|
51
|
-
const fresh = rows.filter(r => !seen.has(Number(r.seq)))
|
|
88
|
+
const fresh = rows.filter((r) => !seen.has(Number(r.seq)))
|
|
52
89
|
const byTable = new Map<string, Set<string> | null>()
|
|
53
90
|
for (const r of fresh) {
|
|
54
91
|
seen.add(Number(r.seq))
|
|
@@ -62,11 +99,14 @@ export function mysqlChanges({ pool, tables, interval = 500 }: Options) {
|
|
|
62
99
|
}
|
|
63
100
|
for (const s of seen) if (s <= mark - BEHIND) seen.delete(s)
|
|
64
101
|
const list: Change[] = []
|
|
65
|
-
for (const [table, ids] of byTable)
|
|
102
|
+
for (const [table, ids] of byTable)
|
|
103
|
+
ids ? list.push(...[...ids].map((id) => ({ table, id }))) : list.push({ table })
|
|
66
104
|
if (list.length) sink.changes(list)
|
|
67
105
|
if (Date.now() - pruned > PRUNE) {
|
|
68
106
|
pruned = Date.now()
|
|
69
|
-
await pool.query(
|
|
107
|
+
await pool.query(
|
|
108
|
+
`delete from ${LOG} where at < now() - interval ${KEEP_HOURS} hour limit 10000`,
|
|
109
|
+
)
|
|
70
110
|
}
|
|
71
111
|
} catch (e) {
|
|
72
112
|
failed = true
|
|
@@ -13,147 +13,226 @@ const PROBE = 1500
|
|
|
13
13
|
const HEARTBEAT = 5_000
|
|
14
14
|
const MAX_BARRIER = 7_800
|
|
15
15
|
|
|
16
|
-
interface Options {
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
interface Options {
|
|
17
|
+
url: string
|
|
18
|
+
listenUrl?: string
|
|
19
|
+
client: Sql
|
|
20
|
+
tables: string[]
|
|
21
|
+
interval?: number
|
|
22
|
+
heartbeat?: number
|
|
23
|
+
}
|
|
21
24
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
25
|
+
/** Sends a NOTIFY and waits to hear it on a listening connection: the only proof a pooler can't fake. */
|
|
26
|
+
async function hears(direct: string, client: Sql): Promise<boolean | string> {
|
|
27
|
+
const sql = postgres(direct, { ...opts, max: 1 })
|
|
28
|
+
const token = `?${crypto.randomUUID()}`
|
|
29
|
+
let heard!: () => void
|
|
30
|
+
const got = new Promise<void>((ok) => {
|
|
31
|
+
heard = ok
|
|
32
|
+
})
|
|
33
|
+
try {
|
|
34
|
+
await sql.listen(CHANNEL, (p) => {
|
|
35
|
+
if (p === token) heard()
|
|
36
|
+
})
|
|
37
|
+
await client.notify(CHANNEL, token)
|
|
38
|
+
return await Promise.race([
|
|
39
|
+
got.then(() => true),
|
|
40
|
+
new Promise<boolean>((ok) => {
|
|
41
|
+
setTimeout(ok, PROBE, false)
|
|
42
|
+
}),
|
|
43
|
+
])
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return (e as Error | null)?.message ?? String(e)
|
|
46
|
+
} finally {
|
|
47
|
+
void sql.end({ timeout: 0 }).catch(() => {})
|
|
37
48
|
}
|
|
49
|
+
}
|
|
38
50
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
51
|
+
async function triggers(client: Sql, tables: string[]): Promise<string[]> {
|
|
52
|
+
const rows =
|
|
53
|
+
await client`select c.relname from pg_trigger t join pg_class c on c.oid = t.tgrelid where t.tgname = 'weft_changes_upd' and c.relname = any(${tables})`
|
|
54
|
+
return rows.map((r) => r.relname as string)
|
|
55
|
+
}
|
|
43
56
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
57
|
+
/** Whether this role may add them, for the error's fix. */
|
|
58
|
+
async function mayCreate(client: Sql, table: string): Promise<boolean> {
|
|
59
|
+
const [r] =
|
|
60
|
+
await client`select has_table_privilege(current_user, ${table}, 'TRIGGER') as ok`.catch(() => [
|
|
61
|
+
{ ok: false },
|
|
62
|
+
])
|
|
63
|
+
return !!r?.ok
|
|
64
|
+
}
|
|
49
65
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
66
|
+
async function stats(client: Sql): Promise<boolean> {
|
|
67
|
+
const [r] = await client`select current_setting('track_counts') = 'on' as ok`.catch(() => [
|
|
68
|
+
{ ok: false },
|
|
69
|
+
])
|
|
70
|
+
return !!r?.ok
|
|
71
|
+
}
|
|
54
72
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
73
|
+
const poll = async (client: Sql, interval: number, why: string, fix: string): Promise<Probe> =>
|
|
74
|
+
(await stats(client))
|
|
75
|
+
? {
|
|
76
|
+
mode: 'poll',
|
|
77
|
+
via: 'postgres-stats',
|
|
78
|
+
why: `${why}. Reading table counters every ${interval / 1000} s: writes show up late, and by table only`,
|
|
79
|
+
fix,
|
|
80
|
+
}
|
|
81
|
+
: {
|
|
82
|
+
mode: 'off',
|
|
83
|
+
via: 'none',
|
|
84
|
+
why: `${why}, and \`track_counts\` is off, so there is nothing to poll`,
|
|
85
|
+
fix,
|
|
86
|
+
}
|
|
59
87
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
88
|
+
async function probe(
|
|
89
|
+
direct: string,
|
|
90
|
+
client: Sql,
|
|
91
|
+
tables: string[],
|
|
92
|
+
interval: number,
|
|
93
|
+
): Promise<Probe> {
|
|
94
|
+
let have: string[]
|
|
95
|
+
try {
|
|
96
|
+
have = await triggers(client, tables)
|
|
97
|
+
} catch (e) {
|
|
98
|
+
return {
|
|
99
|
+
mode: 'off',
|
|
100
|
+
via: 'none',
|
|
101
|
+
why: `the database didn't answer: ${((e as Error | null)?.message ?? String(e)).replace(SECRET, '//…@')}`,
|
|
102
|
+
fix: 'check `DATABASE_URL`',
|
|
71
103
|
}
|
|
72
|
-
const ok = await hears()
|
|
73
|
-
if (ok === true) return { mode: 'feed', via: 'postgres-notify' }
|
|
74
|
-
const pooler = /:6432\b|-pooler\.|pooler\./.test(direct)
|
|
75
|
-
const why = typeof ok === 'string' ? `LISTEN failed: ${ok.replace(SECRET, '//…@')}` : `a NOTIFY never reached a LISTEN on this connection${pooler ? ', which looks like a pooler' : ''}: a pooler in transaction mode does that`
|
|
76
|
-
return poll(why, 'give `dbChanges` a direct connection: `{ listenUrl }`')
|
|
77
104
|
}
|
|
105
|
+
const missing = tables.filter((t) => !have.includes(t))
|
|
106
|
+
if (missing.length) {
|
|
107
|
+
const allowed = await mayCreate(client, missing[0]!)
|
|
108
|
+
return poll(
|
|
109
|
+
client,
|
|
110
|
+
interval,
|
|
111
|
+
`no trigger on ${missing.map((t) => `\`${t}\``).join(', ')}`,
|
|
112
|
+
allowed
|
|
113
|
+
? 'run `weft db live`, then apply the migration'
|
|
114
|
+
: `run \`weft db live\`, and apply the migration as a role that may create triggers: this one has no TRIGGER on \`${missing[0]}\``,
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
const ok = await hears(direct, client)
|
|
118
|
+
if (ok === true) return { mode: 'feed', via: 'postgres-notify' }
|
|
119
|
+
const pooler = /:6432\b|-pooler\.|pooler\./.test(direct)
|
|
120
|
+
const why =
|
|
121
|
+
typeof ok === 'string'
|
|
122
|
+
? `LISTEN failed: ${ok.replace(SECRET, '//…@')}`
|
|
123
|
+
: `a NOTIFY never reached a LISTEN on this connection${pooler ? ', which looks like a pooler' : ''}: a pooler in transaction mode does that`
|
|
124
|
+
return poll(client, interval, why, 'give `dbChanges` a direct connection: `{ listenUrl }`')
|
|
125
|
+
}
|
|
78
126
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
127
|
+
/** A self-addressed NOTIFY proves the listener is alive: a half-open TCP connection looks fine until then. */
|
|
128
|
+
async function listen(sink: Sink, direct: string, client: Sql, heartbeat: number, late: number) {
|
|
129
|
+
let sql: Sql | undefined,
|
|
130
|
+
started = false,
|
|
131
|
+
stopped = false,
|
|
132
|
+
seen = Date.now(),
|
|
133
|
+
timer: ReturnType<typeof setTimeout> | undefined
|
|
134
|
+
const token = `?${crypto.randomUUID()}`
|
|
135
|
+
const heard = (payload: string) => {
|
|
136
|
+
seen = Date.now()
|
|
137
|
+
if (payload[0] === '?') return
|
|
138
|
+
const m = JSON.parse(payload)
|
|
139
|
+
if (m.b) return sink.settled(m.b)
|
|
140
|
+
sink.changes(m.i ? m.i.map((id: string) => ({ table: m.t, id })) : [{ table: m.t }])
|
|
141
|
+
}
|
|
142
|
+
const connect = async () => {
|
|
143
|
+
const mine = (sql = postgres(direct, { ...opts, max: 1, idle_timeout: 0 }))
|
|
144
|
+
await mine.listen(CHANNEL, heard, () => {
|
|
84
145
|
seen = Date.now()
|
|
85
|
-
if (
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
})
|
|
98
|
-
}
|
|
99
|
-
const check = async () => {
|
|
100
|
-
if (stopped) return
|
|
101
|
-
if (Date.now() - seen > late) {
|
|
102
|
-
sink.down('the database listener is not receiving')
|
|
103
|
-
const old = sql
|
|
104
|
-
sql = undefined
|
|
105
|
-
void old?.end({ timeout: 0 }).catch(() => {})
|
|
106
|
-
await connect().catch(() => {}) // the next check tries again
|
|
107
|
-
}
|
|
108
|
-
await client.notify(CHANNEL, token).catch(() => sink.down('the database is not answering'))
|
|
109
|
-
if (stopped) return
|
|
110
|
-
timer = setTimeout(() => void check(), heartbeat)
|
|
111
|
-
;(timer as { unref?(): void }).unref?.()
|
|
146
|
+
if (started) sink.lost() // what committed while it was gone is unknown
|
|
147
|
+
started = true
|
|
148
|
+
sink.up()
|
|
149
|
+
})
|
|
150
|
+
}
|
|
151
|
+
const check = async () => {
|
|
152
|
+
if (stopped) return
|
|
153
|
+
if (Date.now() - seen > late) {
|
|
154
|
+
sink.down('the database listener is not receiving')
|
|
155
|
+
const old = sql
|
|
156
|
+
sql = undefined
|
|
157
|
+
void old?.end({ timeout: 0 }).catch(() => {})
|
|
158
|
+
await connect().catch(() => {}) // the next check tries again
|
|
112
159
|
}
|
|
113
|
-
await
|
|
160
|
+
await client.notify(CHANNEL, token).catch(() => sink.down('the database is not answering'))
|
|
161
|
+
if (stopped) return
|
|
114
162
|
timer = setTimeout(() => void check(), heartbeat)
|
|
115
163
|
;(timer as { unref?(): void }).unref?.()
|
|
116
|
-
return async () => {
|
|
117
|
-
stopped = true
|
|
118
|
-
clearTimeout(timer)
|
|
119
|
-
await sql?.end({ timeout: 0 }).catch(() => {})
|
|
120
|
-
}
|
|
121
164
|
}
|
|
165
|
+
await connect().catch((e) =>
|
|
166
|
+
sink.down(
|
|
167
|
+
`the database listener can't connect: ${String(e?.message ?? e).replace(SECRET, '//…@')}`,
|
|
168
|
+
),
|
|
169
|
+
)
|
|
170
|
+
timer = setTimeout(() => void check(), heartbeat)
|
|
171
|
+
;(timer as { unref?(): void }).unref?.()
|
|
172
|
+
return async () => {
|
|
173
|
+
stopped = true
|
|
174
|
+
clearTimeout(timer)
|
|
175
|
+
await sql?.end({ timeout: 0 }).catch(() => {})
|
|
176
|
+
}
|
|
177
|
+
}
|
|
122
178
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
;(timer as { unref?(): void }).unref?.()
|
|
179
|
+
/** `postgres-stats`: counters that only ever grow, per table, by the second. Nothing to install. */
|
|
180
|
+
function counters(sink: Sink, client: Sql, tables: string[], interval: number) {
|
|
181
|
+
const seen = new Map<string, string>()
|
|
182
|
+
let stopped = false,
|
|
183
|
+
timer: ReturnType<typeof setTimeout> | undefined
|
|
184
|
+
const look = async () => {
|
|
185
|
+
if (stopped) return
|
|
186
|
+
try {
|
|
187
|
+
const rows =
|
|
188
|
+
await client`select relname, n_tup_ins + n_tup_upd + n_tup_del + n_mod_since_analyze as n from pg_stat_user_tables where relname = any(${tables})`
|
|
189
|
+
sink.up()
|
|
190
|
+
const changed = rows.filter((r) => {
|
|
191
|
+
const was = seen.get(r.relname)
|
|
192
|
+
seen.set(r.relname, String(r.n))
|
|
193
|
+
return was !== undefined && was !== String(r.n)
|
|
194
|
+
})
|
|
195
|
+
if (changed.length) sink.changes(changed.map((r) => ({ table: r.relname as string })))
|
|
196
|
+
} catch {
|
|
197
|
+
sink.down('the database is not answering')
|
|
143
198
|
}
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
clearTimeout(timer)
|
|
147
|
-
})
|
|
199
|
+
timer = setTimeout(() => void look(), interval)
|
|
200
|
+
;(timer as { unref?(): void }).unref?.()
|
|
148
201
|
}
|
|
202
|
+
return look().then(() => () => {
|
|
203
|
+
stopped = true
|
|
204
|
+
clearTimeout(timer)
|
|
205
|
+
})
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function postgresChanges({
|
|
209
|
+
url,
|
|
210
|
+
listenUrl,
|
|
211
|
+
client,
|
|
212
|
+
tables,
|
|
213
|
+
interval = 2000,
|
|
214
|
+
heartbeat = HEARTBEAT,
|
|
215
|
+
}: Options) {
|
|
216
|
+
const direct = listenUrl ?? url
|
|
217
|
+
const late = heartbeat * 2
|
|
149
218
|
|
|
150
219
|
return {
|
|
151
|
-
probe,
|
|
152
|
-
start: (sink: Sink, how: Probe) =>
|
|
220
|
+
probe: () => probe(direct, client, tables, interval),
|
|
221
|
+
start: (sink: Sink, how: Probe) =>
|
|
222
|
+
how.via === 'postgres-notify'
|
|
223
|
+
? listen(sink, direct, client, heartbeat, late)
|
|
224
|
+
: how.mode === 'poll'
|
|
225
|
+
? counters(sink, client, tables, interval)
|
|
226
|
+
: Promise.resolve(() => {}),
|
|
153
227
|
/** After the mutation committed, so it arrives after that commit's changes, and before the poke it stands for. */
|
|
154
228
|
async settle(tags: string[]) {
|
|
155
229
|
const text = JSON.stringify({ b: tags })
|
|
156
|
-
await client.notify(
|
|
230
|
+
await client.notify(
|
|
231
|
+
CHANNEL,
|
|
232
|
+
text.length < MAX_BARRIER
|
|
233
|
+
? text
|
|
234
|
+
: JSON.stringify({ b: [...new Set(tags.map((t) => t.split(':')[0]))] }),
|
|
235
|
+
)
|
|
157
236
|
},
|
|
158
237
|
}
|
|
159
238
|
}
|