@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
@@ -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): { run(...params: unknown[]): unknown; values(...params: unknown[]): unknown[][] }
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
  }
@@ -10,45 +10,82 @@ const BEHIND = 512
10
10
  const KEEP_HOURS = 1
11
11
  const PRUNE = 60_000
12
12
 
13
- interface Options { pool: Pool; tables: string[]; interval?: number }
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
- export function mysqlChanges({ pool, tables, interval = 500 }: Options) {
20
- async function probe(): Promise<Probe> {
21
- try {
22
- const [rows] = await pool.query(`select event_object_table as t from information_schema.triggers where trigger_schema = database() and trigger_name like 'weft\\_changes\\_%\\_upd'`)
23
- const have = (rows as { t: string }[]).map(r => r.t)
24
- const missing = tables.filter(t => !have.includes(t))
25
- if (!missing.length) return { mode: 'feed', via: 'mysql-log', why: `read every ${interval} ms: MySQL can't push` }
26
- const [grants] = await pool.query('show grants').catch(() => [[]])
27
- const may = (grants as Record<string, string>[]).some(g => /ALL PRIVILEGES|\bTRIGGER\b/.test(Object.values(g)[0] ?? ''))
28
- return { mode: 'off', via: 'none', why: `no trigger on ${missing.map(t => `\`${t}\``).join(', ')}, and MySQL has no counter to fall back on`, fix: may ? 'run `weft db live`, then apply the migration' : 'run `weft db live`, and apply the migration as a user with the TRIGGER privilege' }
29
- } catch (e) {
30
- return { mode: 'off', via: 'none', why: `the database didn't answer: ${said(e)}`, fix: 'check `DATABASE_URL`' }
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, timer: ReturnType<typeof setTimeout> | undefined, failed = false, pruned = Date.now()
37
- const [[{ top }]] = (await pool.query(`select coalesce(max(seq), 0) as top from ${LOG}`)) as unknown as [[{ top: number | string }]]
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 > ?`, [Math.max(0, mark - BEHIND)])) as unknown as [{ seq: Seq }[]]
41
- const seen = new Set<number>(old.map(r => Number(r.seq)))
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(`select seq, tbl, pk from ${LOG} where seq > ? order by seq limit 5000`, [Math.max(0, mark - BEHIND)])) as unknown as [{ seq: Seq; tbl: string; pk: string | null }[]]
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) ids ? list.push(...[...ids].map(id => ({ table, id }))) : list.push({ table })
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(`delete from ${LOG} where at < now() - interval ${KEEP_HOURS} hour limit 10000`)
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 { url: string; listenUrl?: string; client: Sql; tables: string[]; interval?: number; heartbeat?: number }
17
-
18
- export function postgresChanges({ url, listenUrl, client, tables, interval = 2000, heartbeat = HEARTBEAT }: Options) {
19
- const direct = listenUrl ?? url
20
- const late = heartbeat * 2
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
- /** Sends a NOTIFY and waits to hear it on a listening connection: the only proof a pooler can't fake. */
23
- async function hears(): Promise<boolean | string> {
24
- const sql = postgres(direct, { ...opts, max: 1 })
25
- const token = `?${crypto.randomUUID()}`
26
- let heard!: () => void
27
- const got = new Promise<void>(ok => { heard = ok })
28
- try {
29
- await sql.listen(CHANNEL, p => { if (p === token) heard() })
30
- await client.notify(CHANNEL, token)
31
- return await Promise.race([got.then(() => true), new Promise<boolean>(ok => { setTimeout(ok, PROBE, false) })])
32
- } catch (e) {
33
- return (e as Error | null)?.message ?? String(e)
34
- } finally {
35
- void sql.end({ timeout: 0 }).catch(() => {})
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
- async function triggers(): Promise<string[]> {
40
- const rows = 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})`
41
- return rows.map(r => r.relname as string)
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
- /** Whether this role may add them, for the error's fix. */
45
- async function mayCreate(table: string): Promise<boolean> {
46
- const [r] = await client`select has_table_privilege(current_user, ${table}, 'TRIGGER') as ok`.catch(() => [{ ok: false }])
47
- return !!r?.ok
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
- async function stats(): Promise<boolean> {
51
- const [r] = await client`select current_setting('track_counts') = 'on' as ok`.catch(() => [{ ok: false }])
52
- return !!r?.ok
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
- const poll = async (why: string, fix: string): Promise<Probe> =>
56
- (await stats())
57
- ? { mode: 'poll', via: 'postgres-stats', why: `${why}. Reading table counters every ${interval / 1000} s: writes show up late, and by table only`, fix }
58
- : { mode: 'off', via: 'none', why: `${why}, and \`track_counts\` is off, so there is nothing to poll`, fix }
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
- async function probe(): Promise<Probe> {
61
- let have: string[]
62
- try {
63
- have = await triggers()
64
- } catch (e) {
65
- return { mode: 'off', via: 'none', why: `the database didn't answer: ${((e as Error | null)?.message ?? String(e)).replace(SECRET, '//…@')}`, fix: 'check `DATABASE_URL`' }
66
- }
67
- const missing = tables.filter(t => !have.includes(t))
68
- if (missing.length) {
69
- const allowed = await mayCreate(missing[0]!)
70
- return poll(`no trigger on ${missing.map(t => `\`${t}\``).join(', ')}`, allowed ? 'run `weft db live`, then apply the migration' : `run \`weft db live\`, and apply the migration as a role that may create triggers: this one has no TRIGGER on \`${missing[0]}\``)
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
- /** A self-addressed NOTIFY proves the listener is alive: a half-open TCP connection looks fine until then. */
80
- async function listen(sink: Sink) {
81
- let sql: Sql | undefined, started = false, stopped = false, seen = Date.now(), timer: ReturnType<typeof setTimeout> | undefined
82
- const token = `?${crypto.randomUUID()}`
83
- const heard = (payload: string) => {
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 (payload[0] === '?') return
86
- const m = JSON.parse(payload)
87
- if (m.b) return sink.settled(m.b)
88
- sink.changes(m.i ? m.i.map((id: string) => ({ table: m.t, id })) : [{ table: m.t }])
89
- }
90
- const connect = async () => {
91
- const mine = (sql = postgres(direct, { ...opts, max: 1, idle_timeout: 0 }))
92
- await mine.listen(CHANNEL, heard, () => {
93
- seen = Date.now()
94
- if (started) sink.lost() // what committed while it was gone is unknown
95
- started = true
96
- sink.up()
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 connect().catch(e => sink.down(`the database listener can't connect: ${String(e?.message ?? e).replace(SECRET, '//…@')}`))
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
- /** `postgres-stats`: counters that only ever grow, per table, by the second. Nothing to install. */
124
- function counters(sink: Sink) {
125
- const seen = new Map<string, string>()
126
- let stopped = false, timer: ReturnType<typeof setTimeout> | undefined
127
- const look = async () => {
128
- if (stopped) return
129
- try {
130
- const rows = 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})`
131
- sink.up()
132
- const changed = rows.filter(r => {
133
- const was = seen.get(r.relname)
134
- seen.set(r.relname, String(r.n))
135
- return was !== undefined && was !== String(r.n)
136
- })
137
- if (changed.length) sink.changes(changed.map(r => ({ table: r.relname as string })))
138
- } catch {
139
- sink.down('the database is not answering')
140
- }
141
- timer = setTimeout(() => void look(), interval)
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
- return look().then(() => () => {
145
- stopped = true
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) => (how.via === 'postgres-notify' ? listen(sink) : how.mode === 'poll' ? counters(sink) : Promise.resolve(() => {})),
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(CHANNEL, text.length < MAX_BARRIER ? text : JSON.stringify({ b: [...new Set(tags.map(t => t.split(':')[0]))] }))
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
  }