@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.
Files changed (213) hide show
  1. package/README.md +36 -3
  2. package/crate/Cargo.lock +1347 -0
  3. package/crate/Cargo.toml +72 -0
  4. package/crate/rust-toolchain.toml +3 -0
  5. package/crate/src/build/emit.rs +103 -0
  6. package/crate/src/build.rs +367 -0
  7. package/crate/src/ctx.rs +186 -0
  8. package/crate/src/envelope.rs +163 -0
  9. package/crate/src/functions.rs +173 -0
  10. package/crate/src/lib.rs +118 -0
  11. package/crate/src/native.rs +212 -0
  12. package/crate/src/otel.rs +62 -0
  13. package/crate/src/server.rs +203 -0
  14. package/package.json +46 -37
  15. package/src/app.mjs +51 -10
  16. package/src/asset-receipt.d.mts +13 -0
  17. package/src/asset-receipt.mjs +61 -0
  18. package/src/auth/better-auth.ts +215 -17
  19. package/src/auth/drizzle.ts +1 -1
  20. package/src/auth.ts +21 -6
  21. package/src/boot.ts +19 -8
  22. package/src/bus/postgres.ts +32 -19
  23. package/src/bus.ts +7 -4
  24. package/src/{capture.mjs → capture.ts} +34 -1
  25. package/src/changes-sql.d.mts +8 -2
  26. package/src/changes-sql.mjs +26 -10
  27. package/src/changes.ts +165 -66
  28. package/src/channel/connections.ts +216 -0
  29. package/src/channel/socket.ts +396 -0
  30. package/src/channel-fake.ts +19 -4
  31. package/src/channel.ts +124 -490
  32. package/src/client.tsx +9 -2
  33. package/src/contract/describe.mjs +231 -0
  34. package/src/contract.mjs +146 -155
  35. package/src/data/cache.ts +354 -0
  36. package/src/data/definitions.ts +94 -0
  37. package/src/data/draft.ts +104 -0
  38. package/src/data/foundation.ts +95 -0
  39. package/src/data/live-api.ts +22 -0
  40. package/src/data/mutation.ts +138 -0
  41. package/src/data/primitives.ts +135 -0
  42. package/src/data/query.ts +106 -0
  43. package/src/data/transport.ts +121 -0
  44. package/src/data/types.ts +303 -0
  45. package/src/data.ts +69 -894
  46. package/src/db/bun-sqlite.d.ts +4 -1
  47. package/src/db/changes-mysql.ts +61 -21
  48. package/src/db/changes-postgres.ts +197 -118
  49. package/src/db/changes-sqlite.ts +131 -64
  50. package/src/db/core.ts +38 -13
  51. package/src/db/index.ts +20 -4
  52. package/src/db/mysql.ts +22 -6
  53. package/src/db/postgres.ts +49 -13
  54. package/src/db/sqlite.ts +53 -27
  55. package/src/derive.d.mts +4 -1
  56. package/src/derive.mjs +136 -39
  57. package/src/dev-runtime.mjs +8 -4
  58. package/src/exec/errors.ts +57 -0
  59. package/src/exec/landing.ts +66 -0
  60. package/src/exec/scope.ts +174 -0
  61. package/src/exec/touch.ts +80 -0
  62. package/src/exec/validation.ts +176 -0
  63. package/src/exec.ts +191 -341
  64. package/src/extension.ts +8 -2
  65. package/src/first-paint.ts +26 -9
  66. package/src/form/value.ts +15 -0
  67. package/src/form.tsx +132 -43
  68. package/src/hub/channels.ts +102 -0
  69. package/src/hub/connections.ts +147 -0
  70. package/src/hub/delivery.ts +62 -0
  71. package/src/hub/feed.ts +168 -0
  72. package/src/hub/presence.ts +64 -0
  73. package/src/hub/route.ts +158 -0
  74. package/src/hub/subscriptions.ts +212 -0
  75. package/src/hub/topic.ts +98 -0
  76. package/src/hub/tracked.ts +12 -0
  77. package/src/hub/types.ts +190 -0
  78. package/src/hub/view.ts +147 -0
  79. package/src/hub.ts +132 -758
  80. package/src/index.ts +84 -10
  81. package/src/inline.mjs +356 -0
  82. package/src/inspect/compare.mjs +162 -0
  83. package/src/inspect/routes.mjs +154 -0
  84. package/src/inspect/samples.mjs +59 -0
  85. package/src/inspect/url.mjs +2 -0
  86. package/src/inspect.mjs +146 -187
  87. package/src/link/election.ts +178 -0
  88. package/src/link/lines.ts +27 -0
  89. package/src/link.ts +97 -168
  90. package/src/list-drizzle.ts +104 -18
  91. package/src/list.ts +127 -36
  92. package/src/listen.ts +32 -6
  93. package/src/live/api.ts +18 -0
  94. package/src/live/backoff.ts +6 -0
  95. package/src/live/frames.ts +42 -0
  96. package/src/live/read.ts +77 -0
  97. package/src/live/sub-types.ts +24 -0
  98. package/src/live/transport.ts +86 -0
  99. package/src/live/types.ts +52 -0
  100. package/src/live-fake.ts +20 -5
  101. package/src/live-target.mjs +113 -35
  102. package/src/live.ts +116 -225
  103. package/src/media.ts +8 -1
  104. package/src/migrations.mjs +14 -6
  105. package/src/mux.ts +161 -64
  106. package/src/og/DefaultCard.tsx +40 -9
  107. package/src/og/fonts.mjs +16 -8
  108. package/src/og/images.mjs +43 -22
  109. package/src/og/index.mjs +119 -43
  110. package/src/og/native.mjs +11 -4
  111. package/src/range.mjs +15 -3
  112. package/src/router.ts +56 -6
  113. package/src/routes.mjs +73 -22
  114. package/src/run/admission.ts +80 -0
  115. package/src/run/boot.tsx +115 -0
  116. package/src/run/cache-presentation.ts +67 -0
  117. package/src/run/cache.ts +254 -0
  118. package/src/run/helpers.ts +197 -0
  119. package/src/run/listeners.ts +155 -0
  120. package/src/run/navigation.ts +382 -0
  121. package/src/run/page-skeleton.tsx +35 -0
  122. package/src/run/prefetch.ts +128 -0
  123. package/src/run/retain-view.ts +18 -0
  124. package/src/run/shape-preparation.ts +97 -0
  125. package/src/run/shape-styles.ts +72 -0
  126. package/src/run/source-context/pin.ts +124 -0
  127. package/src/run/source-context.ts +311 -0
  128. package/src/run.tsx +50 -174
  129. package/src/rust/common.ts +163 -0
  130. package/src/rust/native.ts +282 -0
  131. package/src/rust.ts +103 -229
  132. package/src/server/card.tsx +65 -0
  133. package/src/server/compression.ts +115 -0
  134. package/src/server/core.ts +70 -0
  135. package/src/server/dispatch.ts +70 -0
  136. package/src/server/form.ts +85 -0
  137. package/src/server/functions.ts +129 -0
  138. package/src/server/handler.ts +39 -0
  139. package/src/server/head.ts +96 -0
  140. package/src/server/media.ts +60 -0
  141. package/src/server/navigation-cache.ts +80 -0
  142. package/src/server/navigation.tsx +85 -0
  143. package/src/server/page-cold.ts +95 -0
  144. package/src/server/page-stream.tsx +181 -0
  145. package/src/server/page-types.ts +51 -0
  146. package/src/server/page-warm.ts +130 -0
  147. package/src/server/page.ts +69 -0
  148. package/src/server/payload.ts +89 -0
  149. package/src/server/prerender.ts +60 -0
  150. package/src/server/preview.tsx +67 -0
  151. package/src/server/render.ts +167 -0
  152. package/src/server/request.ts +98 -0
  153. package/src/server/resolver.ts +228 -0
  154. package/src/server/routes.ts +150 -0
  155. package/src/server/services.ts +69 -0
  156. package/src/server/text.ts +29 -0
  157. package/src/server/types.ts +160 -0
  158. package/src/server.tsx +82 -964
  159. package/src/shape.mjs +30 -6
  160. package/src/share/display.ts +50 -0
  161. package/src/share/network.ts +119 -0
  162. package/src/share/receive.ts +70 -0
  163. package/src/share/state.ts +30 -0
  164. package/src/share/tab-queue.ts +28 -0
  165. package/src/share-server.ts +128 -43
  166. package/src/share.ts +103 -209
  167. package/src/sheets.mjs +30 -18
  168. package/src/sidecar/build.mjs +170 -0
  169. package/src/sidecar/cargo.mjs +164 -0
  170. package/src/sidecar/common.mjs +17 -0
  171. package/src/sidecar/dev-build.mjs +91 -0
  172. package/src/sidecar/dev-watch.mjs +88 -0
  173. package/src/sidecar/manifest.mjs +19 -0
  174. package/src/sidecar/process.mjs +58 -0
  175. package/src/sidecar/refresh.mjs +19 -0
  176. package/src/sidecar.mjs +75 -416
  177. package/src/socket/check.ts +50 -0
  178. package/src/socket/deliver.ts +16 -0
  179. package/src/socket/publish.ts +56 -0
  180. package/src/socket/rooms.ts +41 -0
  181. package/src/socket/route.ts +79 -0
  182. package/src/socket/shared.ts +26 -0
  183. package/src/socket/types.ts +48 -0
  184. package/src/socket.ts +205 -194
  185. package/src/stream.ts +10 -1
  186. package/src/verify/app.ts +39 -0
  187. package/src/verify/cleanup.ts +27 -0
  188. package/src/verify/database.ts +11 -0
  189. package/src/verify/live.ts +63 -0
  190. package/src/verify/notify.ts +35 -0
  191. package/src/verify/skip.ts +19 -0
  192. package/src/verify/support.ts +149 -0
  193. package/src/verify/timing.ts +15 -0
  194. package/src/verify/trigger.ts +14 -0
  195. package/src/verify.ts +77 -176
  196. package/src/view.tsx +96 -11
  197. package/src/vite/asset-bytes.mjs +95 -0
  198. package/src/vite/assets.mjs +117 -0
  199. package/src/vite/config-hooks.mjs +166 -0
  200. package/src/vite/dev-hooks.mjs +103 -0
  201. package/src/vite/dev.mjs +102 -0
  202. package/src/vite/entries.mjs +145 -0
  203. package/src/vite/module-hooks.mjs +288 -0
  204. package/src/vite/route-modules.mjs +289 -0
  205. package/src/vite/server-functions.mjs +222 -0
  206. package/src/vite/shape-entry-css.mjs +187 -0
  207. package/src/vite/shape-override.mjs +226 -0
  208. package/src/vite/shared.mjs +51 -0
  209. package/src/vite/sheets.mjs +92 -0
  210. package/src/vite/state.mjs +89 -0
  211. package/src/vite/syntax.mjs +139 -0
  212. package/src/vite.mjs +152 -853
  213. package/src/capture.d.mts +0 -1
package/src/bus.ts CHANGED
@@ -19,9 +19,9 @@ export function slotBus(plugin: string, load: () => Promise<Bus>): Bus {
19
19
  const get = () => (bus ??= load())
20
20
  return {
21
21
  name: plugin.replace(/^.*realtime-/, ''),
22
- publish: (channel, message) => get().then(b => b.publish(channel, message)),
23
- subscribe: (channel, on, gap) => get().then(b => b.subscribe(channel, on, gap)),
24
- close: () => (bus ? bus.then(b => b.close?.()) : Promise.resolve()),
22
+ publish: (channel, message) => get().then((b) => b.publish(channel, message)),
23
+ subscribe: (channel, on, gap) => get().then((b) => b.subscribe(channel, on, gap)),
24
+ close: () => (bus ? bus.then((b) => b.close?.()) : Promise.resolve()),
25
25
  }
26
26
  }
27
27
 
@@ -32,7 +32,10 @@ export function memoryBus(): Bus {
32
32
  name: 'memory',
33
33
  publish(channel, message) {
34
34
  const list = channels.get(channel)
35
- if (list) queueMicrotask(() => { for (const on of list) on(message) })
35
+ if (list)
36
+ queueMicrotask(() => {
37
+ for (const on of list) on(message)
38
+ })
36
39
  return Promise.resolve()
37
40
  },
38
41
  subscribe(channel, on) {
@@ -12,4 +12,37 @@
12
12
  const CODE = `(n=>{var d=document,c=navigator.connection,q=window.__wq={q:[],n:n,w(){},d:matchMedia("(prefers-reduced-data: reduce)").matches||!!(c&&c.saveData),p(e){var t=e.target,r=q.r,s=e.submitter;if(t.isConnected){q.r=1;try{e.type=="submit"?t.requestSubmit(s&&s.isConnected?s:void 0):e.type=="reset"?t.reset():t.dispatchEvent(new e.constructor(e.type,e))}finally{q.r=r}}}},o=t=>t.closest&&t.closest("[wk]"),h=e=>{var k=!q.r&&o(e.target);if(k&&(!k.$k||q.q.length)&&!((e.metaKey||e.ctrlKey||e.shiftKey)&&e.type=="click"&&e.target.closest("a"))){q.q.push(e);/^(click|submit|reset)$/.test(e.type)&&e.preventDefault();q.w(k)}},l=e=>{var k=o(e.target);k&&!k.$k&&!q.d&&q.w(k,1)};"click dblclick pointerdown pointerup mousedown mouseup keydown keyup input change submit reset focus blur focusin focusout".split(" ").forEach(t=>d.addEventListener(t,h,!0));"pointerover touchstart".split(" ").forEach(t=>d.addEventListener(t,l,{capture:!0,passive:!0}))})`
13
13
 
14
14
  /** The script's text. `now`: the page has work that can't wait, so its code loads straight away. */
15
- export const capture = now => `${CODE}(${now ? 1 : 0})`
15
+ export const capture = (now: boolean) => `${CODE}(${now ? 1 : 0})`
16
+
17
+ /**
18
+ * A page that runs no client until it's needed follows its links with full loads, so the browser prefetches the one the
19
+ * pointer rests on (`moderate`). Not a prerender, which would run the next page's code unseen. An island's links go
20
+ * through the router once its code comes, so they're left out, as is `/_weft/` and anything under `data-preload="off"`;
21
+ * `start()` takes the rules down when the router takes over, so nothing is fetched twice.
22
+ */
23
+ const RULES = JSON.stringify({
24
+ prefetch: [
25
+ {
26
+ source: 'document',
27
+ where: {
28
+ and: [
29
+ { href_matches: '/*' },
30
+ { not: { href_matches: '/_weft/*' } },
31
+ {
32
+ not: {
33
+ selector_matches:
34
+ '[wk] a, a[wk], [data-preload="off"] a, a[data-preload="off"], a[download]',
35
+ },
36
+ },
37
+ ],
38
+ },
39
+ eagerness: 'moderate',
40
+ },
41
+ ],
42
+ })
43
+
44
+ /** The rules, for a page whose code waits (`now` false, nothing streaming). */
45
+ export const speculate = (shape: { now: boolean; streaming?: boolean }) =>
46
+ shape.now || shape.streaming
47
+ ? ''
48
+ : `<script type="speculationrules" id="weft-speculate">${RULES}</script>\n`
@@ -9,6 +9,12 @@ export const LOG_ROWS: number
9
9
  /** Between statements of generated SQL, as Drizzle splits them. */
10
10
  export const BREAK: string
11
11
  /** The trigger statements for these tables, one by one. */
12
- export function changeStatements(dialect: string, tables: readonly { name: string; pk?: string[] }[]): string[]
12
+ export function changeStatements(
13
+ dialect: string,
14
+ tables: readonly { name: string; pk?: string[] }[],
15
+ ): string[]
13
16
  /** The same statements as one migration. */
14
- export function changeSql(dialect: string, tables: readonly { name: string; pk?: string[] }[]): string
17
+ export function changeSql(
18
+ dialect: string,
19
+ tables: readonly { name: string; pk?: string[] }[],
20
+ ): string
@@ -14,10 +14,10 @@ export const MAX_IDS = 32
14
14
  export const LOG_ROWS = 4096
15
15
  export const BREAK = '\n--> statement-breakpoint\n'
16
16
 
17
- const pgName = n => `"${n.replaceAll('"', '""')}"`
18
- const myName = n => `\`${n.replaceAll('`', '``')}\``
19
- const literal = s => `'${s.replaceAll("'", "''")}'`
20
- const safe = t => t.replace(/\W/g, '_')
17
+ const pgName = (n) => `"${n.replaceAll('"', '""')}"`
18
+ const myName = (n) => `\`${n.replaceAll('`', '``')}\``
19
+ const literal = (s) => `'${s.replaceAll("'", "''")}'`
20
+ const safe = (t) => t.replace(/\W/g, '_')
21
21
 
22
22
  function postgres(tables) {
23
23
  const fn = `CREATE OR REPLACE FUNCTION weft_changes_notify() RETURNS trigger AS $weft$
@@ -48,7 +48,12 @@ $weft$ LANGUAGE plpgsql`
48
48
  `DROP TRIGGER IF EXISTS weft_changes_${suffix} ON ${on}`,
49
49
  `CREATE TRIGGER weft_changes_${suffix} AFTER ${event} ON ${on} ${rows ? `REFERENCING ${rows} TABLE AS weft_rows ` : ''}FOR EACH STATEMENT EXECUTE FUNCTION weft_changes_notify(${args})`,
50
50
  ]
51
- return [...trigger('ins', 'INSERT', 'NEW'), ...trigger('upd', 'UPDATE', 'NEW'), ...trigger('del', 'DELETE', 'OLD'), ...trigger('trn', 'TRUNCATE')]
51
+ return [
52
+ ...trigger('ins', 'INSERT', 'NEW'),
53
+ ...trigger('upd', 'UPDATE', 'NEW'),
54
+ ...trigger('del', 'DELETE', 'OLD'),
55
+ ...trigger('trn', 'TRUNCATE'),
56
+ ]
52
57
  })
53
58
  return [fn, ...list]
54
59
  }
@@ -56,7 +61,8 @@ $weft$ LANGUAGE plpgsql`
56
61
  function sqlite(tables) {
57
62
  const log = `CREATE TABLE IF NOT EXISTS ${LOG} (seq INTEGER PRIMARY KEY AUTOINCREMENT, tbl TEXT NOT NULL, pk TEXT)`
58
63
  const list = tables.flatMap(({ name, pk }) => {
59
- const id = row => (pk.length ? pk.map(c => `${row}.${pgName(c)}`).join(` || ':' || `) : 'NULL')
64
+ const id = (row) =>
65
+ pk.length ? pk.map((c) => `${row}.${pgName(c)}`).join(` || ':' || `) : 'NULL'
60
66
  const trigger = (suffix, event, row) => [
61
67
  `DROP TRIGGER IF EXISTS weft_changes_${safe(name)}_${suffix}`,
62
68
  `CREATE TRIGGER weft_changes_${safe(name)}_${suffix} AFTER ${event} ON ${pgName(name)} BEGIN
@@ -64,7 +70,11 @@ function sqlite(tables) {
64
70
  DELETE FROM ${LOG} WHERE seq <= last_insert_rowid() - ${LOG_ROWS};
65
71
  END`,
66
72
  ]
67
- return [...trigger('ins', 'INSERT', 'NEW'), ...trigger('upd', 'UPDATE', 'NEW'), ...trigger('del', 'DELETE', 'OLD')]
73
+ return [
74
+ ...trigger('ins', 'INSERT', 'NEW'),
75
+ ...trigger('upd', 'UPDATE', 'NEW'),
76
+ ...trigger('del', 'DELETE', 'OLD'),
77
+ ]
68
78
  })
69
79
  return [log, ...list]
70
80
  }
@@ -72,12 +82,17 @@ END`,
72
82
  function mysql(tables) {
73
83
  const log = `CREATE TABLE IF NOT EXISTS ${LOG} (seq BIGINT AUTO_INCREMENT PRIMARY KEY, tbl VARCHAR(64) NOT NULL, pk VARCHAR(255), at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, KEY at_idx (at))`
74
84
  const list = tables.flatMap(({ name, pk }) => {
75
- const id = row => (pk.length ? `CONCAT_WS(':', ${pk.map(c => `${row}.${myName(c)}`).join(', ')})` : 'NULL')
85
+ const id = (row) =>
86
+ pk.length ? `CONCAT_WS(':', ${pk.map((c) => `${row}.${myName(c)}`).join(', ')})` : 'NULL'
76
87
  const trigger = (suffix, event, row) => [
77
88
  `DROP TRIGGER IF EXISTS weft_changes_${safe(name)}_${suffix}`,
78
89
  `CREATE TRIGGER weft_changes_${safe(name)}_${suffix} AFTER ${event} ON ${myName(name)} FOR EACH ROW INSERT INTO ${LOG} (tbl, pk) VALUES (${literal(name)}, ${id(row)})`,
79
90
  ]
80
- return [...trigger('ins', 'INSERT', 'NEW'), ...trigger('upd', 'UPDATE', 'NEW'), ...trigger('del', 'DELETE', 'OLD')]
91
+ return [
92
+ ...trigger('ins', 'INSERT', 'NEW'),
93
+ ...trigger('upd', 'UPDATE', 'NEW'),
94
+ ...trigger('del', 'DELETE', 'OLD'),
95
+ ]
81
96
  })
82
97
  return [log, ...list]
83
98
  }
@@ -96,4 +111,5 @@ export function changeStatements(dialect, tables) {
96
111
  }
97
112
 
98
113
  /** A migration file, in drizzle-kit's format. */
99
- export const changeSql = (dialect, tables) => `-- weft: report changes to ${tables.map(t => t.name).join(', ')} (\`weft db live\`). Ids only, never a row.\n${changeStatements(dialect, tables).join(`;${BREAK}`)};\n`
114
+ export const changeSql = (dialect, tables) =>
115
+ `-- weft: report changes to ${tables.map((t) => t.name).join(', ')} (\`weft db live\`). Ids only, never a row.\n${changeStatements(dialect, tables).join(`;${BREAK}`)};\n`
package/src/changes.ts CHANGED
@@ -20,7 +20,10 @@ import type { DbHandle } from './db/core'
20
20
  export type { LiveMode } from './data'
21
21
 
22
22
  /** What changed: never what it changed to. No `id`: the whole table. */
23
- export interface Change { table: string; id?: string }
23
+ export interface Change {
24
+ table: string
25
+ id?: string
26
+ }
24
27
 
25
28
  /** What a source learned about this database, by trying it. `feed` sees every change; `poll` only estimates. */
26
29
  export interface Probe {
@@ -64,7 +67,10 @@ export interface Source {
64
67
  }
65
68
 
66
69
  /** A table as `Source.defs` reports it. */
67
- export interface TableDef { name: string; pk: string[] }
70
+ export interface TableDef {
71
+ name: string
72
+ pk: string[]
73
+ }
68
74
 
69
75
  /** `dbChanges`: how to listen, and how a change turns into tags. */
70
76
  export interface ChangeOptions {
@@ -79,12 +85,25 @@ export interface ChangeOptions {
79
85
  }
80
86
 
81
87
  const NAME = Symbol.for('drizzle:Name')
82
- const nameOf = (t: unknown): string => (typeof t === 'string' ? t : (t as Record<symbol, string> | null)?.[NAME] ?? (() => { throw new StartError('R017', `\`dbChanges\` got something that isn't a table: ${String(t)}`, 'pass Drizzle tables, or their names') })())
88
+ const nameOf = (t: unknown): string =>
89
+ typeof t === 'string'
90
+ ? t
91
+ : ((t as Record<symbol, string> | null)?.[NAME] ??
92
+ (() => {
93
+ throw new StartError(
94
+ 'R017',
95
+ `\`dbChanges\` got something that isn't a table: ${String(t)}`,
96
+ 'pass Drizzle tables, or their names',
97
+ )
98
+ })())
83
99
 
84
100
  const CORE = { sqlite: 'sqlite-core', postgresql: 'pg-core', mysql: 'mysql-core' }
85
101
 
86
102
  /** A table's primary key columns, from its Drizzle definition. */
87
- interface Key { name: string; primary?: boolean }
103
+ interface Key {
104
+ name: string
105
+ primary?: boolean
106
+ }
88
107
 
89
108
  async function pkOf(table: unknown, dialect: DbHandle['dialect']): Promise<string[]> {
90
109
  const { getTableConfig } = await import(/* @vite-ignore */ `drizzle-orm/${CORE[dialect]}`)
@@ -94,9 +113,18 @@ async function pkOf(table: unknown, dialect: DbHandle['dialect']): Promise<strin
94
113
  }
95
114
 
96
115
  /** `realtime({ changes: dbChanges(db, [todos, notes]) })`: these tables' writes reach `.live`, whoever made them. */
97
- export function dbChanges(db: { $weft?: DbHandle }, tables: unknown[], options: ChangeOptions = {}): Source {
116
+ export function dbChanges(
117
+ db: { $weft?: DbHandle },
118
+ tables: unknown[],
119
+ options: ChangeOptions = {},
120
+ ): Source {
98
121
  const handle = db?.$weft
99
- if (!handle?.changes) throw new StartError('R017', '`dbChanges` needs a client from `@weft-js/start/db/sqlite`, `/postgres` or `/mysql`', 'for MongoDB use `mongoChanges` from `@weft-js/db/mongo`; for another client, write a `Source`')
122
+ if (!handle?.changes)
123
+ throw new StartError(
124
+ 'R017',
125
+ '`dbChanges` needs a client from `@weft-js/start/db/sqlite`, `/postgres` or `/mysql`',
126
+ 'for MongoDB use `mongoChanges` from `@weft-js/db/mongo`; for another client, write a `Source`',
127
+ )
100
128
  const names = tables.map(nameOf)
101
129
  let source: Promise<Omit<Source, 'name' | 'tables'>> | undefined
102
130
  const load = () => (source ??= handle.changes!(names, options))
@@ -106,15 +134,25 @@ export function dbChanges(db: { $weft?: DbHandle }, tables: unknown[], options:
106
134
  tables: names,
107
135
  external: options.listener === 'external',
108
136
  tagged: options.tagged,
109
- defs: () => Promise.all(tables.map(async t => ({ name: nameOf(t), pk: typeof t === 'string' ? [] : await pkOf(t, handle.dialect) }))),
110
- probe: () => load().then(s => s.probe()),
111
- start: (sink, probe) => load().then(s => s.start(sink, probe)),
112
- settle: tags => load().then(s => s.settle?.(tags)),
137
+ defs: () =>
138
+ Promise.all(
139
+ tables.map(async (t) => ({
140
+ name: nameOf(t),
141
+ pk: typeof t === 'string' ? [] : await pkOf(t, handle.dialect),
142
+ })),
143
+ ),
144
+ probe: () => load().then((s) => s.probe()),
145
+ start: (sink, probe) => load().then((s) => s.start(sink, probe)),
146
+ settle: (tags) => load().then((s) => s.settle?.(tags)),
113
147
  }
114
148
  }
115
149
 
116
150
  /** Set by a platform, read at run time: this process lives for one request. */
117
- export function functionHost(env: Record<string, string | undefined> = (globalThis as unknown as { process?: { env: Record<string, string | undefined> } }).process?.env ?? {}): string | undefined {
151
+ export function functionHost(
152
+ env: Record<string, string | undefined> = (
153
+ globalThis as unknown as { process?: { env: Record<string, string | undefined> } }
154
+ ).process?.env ?? {},
155
+ ): string | undefined {
118
156
  if (env.VERCEL) return 'Vercel'
119
157
  if (env.NETLIFY || env.NETLIFY_LOCAL) return 'Netlify'
120
158
  if (env.AWS_LAMBDA_FUNCTION_NAME) return 'AWS Lambda'
@@ -125,7 +163,11 @@ export function functionHost(env: Record<string, string | undefined> = (globalTh
125
163
  }
126
164
 
127
165
  /** What a change sends to live queries, as tags to refetch. */
128
- export interface Poke { t: string[]; /** Tag prefixes: `todos:` for every row of a table-level change. */ p?: string[]; /** Tags a barrier covered: a subscription with one of them is poked already. */ k?: string[] }
166
+ export interface Poke {
167
+ t: string[]
168
+ /** Tag prefixes: `todos:` for every row of a table-level change. */ p?: string[]
169
+ /** Tags a barrier covered: a subscription with one of them is poked already. */ k?: string[]
170
+ }
129
171
 
130
172
  /** Where a feed's pokes go: this instance, and the rest through the bus. */
131
173
  export interface FeedOut {
@@ -145,23 +187,116 @@ export interface Feed {
145
187
  readonly running: boolean
146
188
  }
147
189
 
148
- const unref = <T,>(t: T) => ((t as { unref?(): void } | null | undefined)?.unref?.(), t)
190
+ const unref = <T>(t: T) => ((t as { unref?(): void } | null | undefined)?.unref?.(), t)
191
+
192
+ function feedHandle(
193
+ source: Source,
194
+ known: Set<string>,
195
+ sink: Sink,
196
+ cancel: () => void,
197
+ barrier: number,
198
+ ): Feed {
199
+ // stopped → starting → running → stopped. A start that resolves after stop() (its turn has passed) is stopped at
200
+ // once, so a listener can't outlive the feed.
201
+ let phase: 'stopped' | 'starting' | 'running' = 'stopped'
202
+ let turn = 0
203
+ let stopping: (() => unknown) | undefined
204
+ return {
205
+ get running() {
206
+ return phase !== 'stopped'
207
+ },
208
+ covers(tag) {
209
+ const at = tag.indexOf(':')
210
+ return known.has(at < 0 ? tag : tag.slice(0, at))
211
+ },
212
+ async start(probe) {
213
+ if (phase !== 'stopped') return
214
+ phase = 'starting'
215
+ const mine = ++turn
216
+ let stop: (() => unknown) | undefined
217
+ try {
218
+ stop = await source.start(sink, probe)
219
+ } catch (e) {
220
+ if (turn === mine) phase = 'stopped'
221
+ throw e
222
+ }
223
+ if (turn !== mine) return void (await stop?.())
224
+ stopping = stop
225
+ phase = 'running'
226
+ },
227
+ async stop() {
228
+ cancel()
229
+ turn++
230
+ phase = 'stopped'
231
+ const stop = stopping
232
+ stopping = undefined
233
+ await stop?.()
234
+ },
235
+ async settle(tags) {
236
+ const ours = tags.filter((t) => this.covers(t))
237
+ if (!ours.length || !source.settle) return
238
+ let late: ReturnType<typeof setTimeout> | undefined
239
+ await Promise.race([
240
+ source.settle(ours),
241
+ new Promise<void>((ok) => {
242
+ late = unref(setTimeout(ok, barrier))
243
+ }),
244
+ ]).catch(() => {})
245
+ clearTimeout(late)
246
+ },
247
+ }
248
+ }
249
+
250
+ function addChanges(
251
+ list: Change[],
252
+ o: {
253
+ known: Set<string>
254
+ source: Source
255
+ pending: Set<string>
256
+ covered: Set<string>
257
+ prefixes: Set<string>
258
+ out: FeedOut
259
+ arm: () => void
260
+ },
261
+ ) {
262
+ const { known, source, pending, covered, prefixes, out, arm } = o
263
+ const seen: string[] = []
264
+ for (const c of list) {
265
+ if (!known.has(c.table)) continue
266
+ for (const t of source.tagged?.(c) ??
267
+ (c.id === undefined ? [c.table] : [c.table, `${c.table}:${c.id}`])) {
268
+ pending.add(t)
269
+ covered.delete(t) // a change after the barrier: no poke covers it
270
+ seen.push(t)
271
+ }
272
+ if (c.id === undefined) prefixes.add(`${c.table}:`)
273
+ }
274
+ if (!seen.length) return
275
+ out.bump?.(seen)
276
+ arm()
277
+ }
149
278
 
150
279
  /** Batches a source's changes into pokes, held and throttled so a burst of writes is one refetch. */
151
- export function createFeed(source: Source, out: FeedOut, { hold = source.hold ?? 25, throttle = 250, barrier = 1000 } = {}): Feed {
280
+ export function createFeed(
281
+ source: Source,
282
+ out: FeedOut,
283
+ { hold = source.hold ?? 25, throttle = 250, barrier = 1000 } = {},
284
+ ): Feed {
152
285
  const known = new Set(source.tables)
153
- let pending = new Set<string>(), prefixes = new Set<string>(), covered = new Set<string>()
286
+ let pending = new Set<string>(),
287
+ prefixes = new Set<string>(),
288
+ covered = new Set<string>()
154
289
  let timer: ReturnType<typeof setTimeout> | undefined
155
290
  let last = 0
156
291
  let first: number | undefined
157
- let stopping: (() => unknown) | undefined
158
- let running = false
159
292
  let isDown = false
160
293
 
161
294
  const flush = () => {
162
295
  timer = first = undefined
163
296
  last = Date.now()
164
- const t = [...pending], p = [...prefixes], k = [...covered]
297
+ const t = [...pending],
298
+ p = [...prefixes],
299
+ k = [...covered]
165
300
  pending = new Set()
166
301
  prefixes = new Set()
167
302
  covered = new Set()
@@ -172,27 +307,13 @@ export function createFeed(source: Source, out: FeedOut, { hold = source.hold ??
172
307
  const now = Date.now()
173
308
  first ??= now
174
309
  clearTimeout(timer)
175
- timer = unref(setTimeout(flush, Math.max(Math.min(hold, first + throttle - now), last + throttle - now)))
310
+ timer = unref(
311
+ setTimeout(flush, Math.max(Math.min(hold, first + throttle - now), last + throttle - now)),
312
+ )
176
313
  }
177
314
 
178
- const tagsOf = (change: Change) => source.tagged?.(change) ?? (change.id === undefined ? [change.table] : [change.table, `${change.table}:${change.id}`])
179
-
180
315
  const sink: Sink = {
181
- changes(list) {
182
- const seen: string[] = []
183
- for (const c of list) {
184
- if (!known.has(c.table)) continue
185
- for (const t of tagsOf(c)) {
186
- pending.add(t)
187
- covered.delete(t) // a change after the barrier: no poke covers it
188
- seen.push(t)
189
- }
190
- if (c.id === undefined) prefixes.add(`${c.table}:`)
191
- }
192
- if (!seen.length) return
193
- out.bump?.(seen)
194
- arm()
195
- },
316
+ changes: (list) => addChanges(list, { known, source, pending, covered, prefixes, out, arm }),
196
317
  settled(tags) {
197
318
  for (const t of tags) {
198
319
  if (pending.delete(t)) covered.add(t)
@@ -204,7 +325,7 @@ export function createFeed(source: Source, out: FeedOut, { hold = source.hold ??
204
325
  covered = new Set()
205
326
  },
206
327
  lost() {
207
- sink.changes(source.tables.map(table => ({ table })))
328
+ sink.changes(source.tables.map((table) => ({ table })))
208
329
  },
209
330
  down(why) {
210
331
  if (isDown) return
@@ -218,36 +339,14 @@ export function createFeed(source: Source, out: FeedOut, { hold = source.hold ??
218
339
  },
219
340
  }
220
341
 
221
- return {
222
- get running() { return running },
223
- covers(tag) {
224
- const at = tag.indexOf(':')
225
- return known.has(at < 0 ? tag : tag.slice(0, at))
226
- },
227
- async start(probe) {
228
- if (running) return
229
- running = true
230
- try {
231
- stopping = await source.start(sink, probe)
232
- } catch (e) {
233
- running = false
234
- throw e
235
- }
236
- },
237
- async stop() {
342
+ return feedHandle(
343
+ source,
344
+ known,
345
+ sink,
346
+ () => {
238
347
  clearTimeout(timer)
239
348
  timer = undefined
240
- running = false
241
- const stop = stopping
242
- stopping = undefined
243
- await stop?.()
244
349
  },
245
- async settle(tags) {
246
- const ours = tags.filter(t => this.covers(t))
247
- if (!ours.length || !source.settle) return
248
- let late: ReturnType<typeof setTimeout> | undefined
249
- await Promise.race([source.settle(ours), new Promise<void>(ok => { late = unref(setTimeout(ok, barrier)) })]).catch(() => {})
250
- clearTimeout(late)
251
- },
252
- }
350
+ barrier,
351
+ )
253
352
  }