@weft-js/start 0.1.1 → 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 +38 -8
  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 +120 -492
  32. package/src/client.tsx +9 -2
  33. package/src/contract/describe.mjs +231 -0
  34. package/src/contract.mjs +144 -157
  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 +90 -166
  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 +112 -37
  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 -178
  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 -971
  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
@@ -1,21 +1,67 @@
1
1
  // The `db.adapter@1` default (research 13 §2.2): a list source over a Drizzle table. `columns` keeps its meaning here,
2
2
  // so a table's other columns stay out of what's selected, returned or written.
3
3
 
4
- import { and, asc, count, desc, eq, gt, inArray, isNull, lt, or, sql, type Column, type SQL } from 'drizzle-orm'
5
- import { likeOf, pageOf, plan, rowOf, storedId, written, type DbAdapter, type ListPage, type ListParams, type ListSource, type SourceSpec } from './list'
4
+ import {
5
+ and,
6
+ asc,
7
+ count,
8
+ desc,
9
+ eq,
10
+ gt,
11
+ inArray,
12
+ isNull,
13
+ lt,
14
+ or,
15
+ sql,
16
+ type Column,
17
+ type SQL,
18
+ } from 'drizzle-orm'
19
+ import {
20
+ likeOf,
21
+ pageOf,
22
+ plan,
23
+ rowOf,
24
+ storedId,
25
+ written,
26
+ type DbAdapter,
27
+ type ListPage,
28
+ type ListParams,
29
+ type ListSource,
30
+ type SourceSpec,
31
+ } from './list'
6
32
 
7
33
  /** The Drizzle client and table a list source runs on. */
8
- export interface DrizzleTarget { db: object; table: object }
34
+ export interface DrizzleTarget {
35
+ db: object
36
+ table: object
37
+ }
9
38
 
10
39
  /** What every dialect's query builders share by name, though no type says so. */
11
- type Chain = PromiseLike<object[]> & { from(t: object): Chain; where(w?: SQL): Chain; orderBy(...o: SQL[]): Chain; limit(n: number): Chain; offset(n: number): Chain; values(v: object): Chain; set(v: object): Chain; returning(s?: object): Chain }
12
- interface Db { select(s: object): Chain; insert(t: object): Chain; update(t: object): Chain; delete(t: object): Chain }
40
+ type Chain = PromiseLike<object[]> & {
41
+ from(t: object): Chain
42
+ where(w?: SQL): Chain
43
+ orderBy(...o: SQL[]): Chain
44
+ limit(n: number): Chain
45
+ offset(n: number): Chain
46
+ values(v: object): Chain
47
+ set(v: object): Chain
48
+ returning(s?: object): Chain
49
+ }
50
+ interface Db {
51
+ select(s: object): Chain
52
+ insert(t: object): Chain
53
+ update(t: object): Chain
54
+ delete(t: object): Chain
55
+ }
13
56
 
14
57
  /** A `ListSource` over a Drizzle table. */
15
- export function drizzleSource<Row = Record<string, unknown>>({ db: handle, table }: DrizzleTarget, spec: SourceSpec): ListSource<Row> {
58
+ export function drizzleSource<Row = Record<string, unknown>>(
59
+ { db: handle, table }: DrizzleTarget,
60
+ spec: SourceSpec,
61
+ ): ListSource<Row> {
16
62
  const db = handle as Db
17
63
  const col = (name: string) => (table as Record<string, Column>)[name]!
18
- const selection = Object.fromEntries(Object.values(spec.columns).map(c => [c, col(c)]))
64
+ const selection = Object.fromEntries(Object.values(spec.columns).map((c) => [c, col(c)]))
19
65
  const public_ = rowOf<Row>(spec)
20
66
  const id = col(storedId(spec))
21
67
  const reading = (rows: object[]) => rows.map(public_)
@@ -23,26 +69,66 @@ export function drizzleSource<Row = Record<string, unknown>>({ db: handle, table
23
69
  return {
24
70
  async list(p: ListParams): Promise<ListPage<Row>> {
25
71
  const q = plan(spec, p)
26
- const filters: (SQL | undefined)[] = q.where.map(w => {
27
- const some = w.values.filter(v => v !== null)
28
- const hit = some.length === 1 ? eq(col(w.column), some[0]) : some.length ? inArray(col(w.column), some) : undefined
72
+ const filters: (SQL | undefined)[] = q.where.map((w) => {
73
+ const some = w.values.filter((v) => v !== null)
74
+ const hit =
75
+ some.length === 1
76
+ ? eq(col(w.column), some[0])
77
+ : some.length
78
+ ? inArray(col(w.column), some)
79
+ : undefined
29
80
  return w.values.includes(null) ? or(hit, isNull(col(w.column))) : hit
30
81
  })
31
- if (q.search) filters.push(or(...q.search.columns.map(c => sql`lower(${col(c)}) like ${likeOf(q.search!.text)} escape '!'`)))
82
+ if (q.search)
83
+ filters.push(
84
+ or(
85
+ ...q.search.columns.map(
86
+ (c) => sql`lower(${col(c)}) like ${likeOf(q.search!.text)} escape '!'`,
87
+ ),
88
+ ),
89
+ )
32
90
  const where = and(...filters)
33
91
  const by = q.sort?.dir === 'desc' ? desc : asc
34
92
  const past = q.sort?.dir === 'desc' ? lt : gt
35
- const keyset = p.after ? (q.sort ? or(past(col(q.sort.column), p.after[0]), and(eq(col(q.sort.column), p.after[0]), past(id, p.after[1]))) : past(id, p.after[1])) : undefined
93
+ const keyset = p.after
94
+ ? q.sort
95
+ ? or(
96
+ past(col(q.sort.column), p.after[0]),
97
+ and(eq(col(q.sort.column), p.after[0]), past(id, p.after[1])),
98
+ )
99
+ : past(id, p.after[1])
100
+ : undefined
36
101
  const order = q.sort ? [by(col(q.sort.column)), by(id)] : [asc(id)]
37
102
  const cursor = p.mode === 'cursor'
38
- const rows = await db.select(selection).from(table).where(and(where, keyset)).orderBy(...order).limit(cursor ? p.limit + 1 : p.limit).offset(p.offset)
39
- const total = cursor ? undefined : ((await db.select({ n: count() }).from(table).where(where)) as { n: number }[])[0]!.n
103
+ const rows = await db
104
+ .select(selection)
105
+ .from(table)
106
+ .where(and(where, keyset))
107
+ .orderBy(...order)
108
+ .limit(cursor ? p.limit + 1 : p.limit)
109
+ .offset(p.offset)
110
+ const total = cursor
111
+ ? undefined
112
+ : ((await db.select({ n: count() }).from(table).where(where)) as { n: number }[])[0]!.n
40
113
  return pageOf(reading(rows), p, { sort: q.sort?.field, id: q.id.field }, total)
41
114
  },
42
- get: id_ => one(db.select(selection).from(table).where(eq(id, id_)).limit(1)),
43
- create: async data => (await one(db.insert(table).values(written(spec, data, true)).returning(selection)))!,
44
- update: (id_, patch) => { const row = written(spec, patch); return one(Object.keys(row).length ? db.update(table).set(row).where(eq(id, id_)).returning(selection) : db.select(selection).from(table).where(eq(id, id_)).limit(1)) },
45
- remove: async id_ => (await db.delete(table).where(eq(id, id_)).returning({ id })).length > 0,
115
+ get: (id_) => one(db.select(selection).from(table).where(eq(id, id_)).limit(1)),
116
+ create: async (data) =>
117
+ (await one(
118
+ db
119
+ .insert(table)
120
+ .values(written(spec, data, true))
121
+ .returning(selection),
122
+ ))!,
123
+ update: (id_, patch) => {
124
+ const row = written(spec, patch)
125
+ return one(
126
+ Object.keys(row).length
127
+ ? db.update(table).set(row).where(eq(id, id_)).returning(selection)
128
+ : db.select(selection).from(table).where(eq(id, id_)).limit(1),
129
+ )
130
+ },
131
+ remove: async (id_) => (await db.delete(table).where(eq(id, id_)).returning({ id })).length > 0,
46
132
  }
47
133
  }
48
134
 
package/src/list.ts CHANGED
@@ -17,7 +17,10 @@ export type Scalar = string | number | boolean | null
17
17
  /** Which way a sort runs. */
18
18
  export type Dir = 'asc' | 'desc'
19
19
  /** One field and a direction. */
20
- export interface Sort { field: string; dir: Dir }
20
+ export interface Sort {
21
+ field: string
22
+ dir: Dir
23
+ }
21
24
 
22
25
  /** A checked request, as a source takes it. */
23
26
  export interface ListParams {
@@ -33,8 +36,13 @@ export interface ListParams {
33
36
  filter: Record<string, Scalar | Scalar[]>
34
37
  }
35
38
 
36
- /** The envelope warp's `Table` reads. `next` and `prev` go back in as `cursor` or `page`; absent means no such page. */
37
- export interface ListPage<T> { items: T[]; total?: number; next?: string | number; prev?: string | number }
39
+ /** The envelope Warp's `Table` reads. `next` and `prev` go back in as `cursor` or `page`; absent means no such page. */
40
+ export interface ListPage<T> {
41
+ items: T[]
42
+ total?: number
43
+ next?: string | number
44
+ prev?: string | number
45
+ }
38
46
 
39
47
  /** What a list lets a client ask for. */
40
48
  export interface ListSpec {
@@ -57,28 +65,42 @@ export interface ListRequest {
57
65
  filter?: Record<string, Scalar | Scalar[]>
58
66
  }
59
67
 
60
- const SEARCH_MAX = 200, IN_MAX = 100, CURSOR_MAX = 1024
68
+ const SEARCH_MAX = 200,
69
+ IN_MAX = 100,
70
+ CURSOR_MAX = 1024
61
71
 
62
- const scalar = (v: unknown): v is Scalar => v === null || typeof v === 'boolean' || typeof v === 'string' || (typeof v === 'number' && Number.isFinite(v))
72
+ const scalar = (v: unknown): v is Scalar =>
73
+ v === null ||
74
+ typeof v === 'boolean' ||
75
+ typeof v === 'string' ||
76
+ (typeof v === 'number' && Number.isFinite(v))
63
77
 
64
78
  const pack = (v: unknown) => (v instanceof Date ? { $d: v.toISOString() } : v)
65
- const stamped = (v: unknown): v is { $d: string } => typeof (v as { $d?: unknown } | null)?.$d === 'string'
79
+ const stamped = (v: unknown): v is { $d: string } =>
80
+ typeof (v as { $d?: unknown } | null)?.$d === 'string'
66
81
  const unpack = (v: unknown) => (stamped(v) ? new Date(v.$d) : v)
67
82
 
68
83
  /** Opaque to a client, and read with the same care as any other input. */
69
84
  export function encodeCursor(value: unknown, id: unknown): string {
70
85
  const bytes = new TextEncoder().encode(JSON.stringify([pack(value), pack(id)]))
71
- return btoa(String.fromCharCode(...bytes)).replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, '')
86
+ return btoa(String.fromCharCode(...bytes))
87
+ .replaceAll('+', '-')
88
+ .replaceAll('/', '_')
89
+ .replace(/=+$/, '')
72
90
  }
73
91
 
74
92
  /** The other way from `encodeCursor`: null for anything that isn't a cursor of ours. */
75
93
  export function decodeCursor(text: string): [unknown, unknown] | null {
76
94
  if (text.length > CURSOR_MAX) return null
77
95
  try {
78
- const bytes = Uint8Array.from(atob(text.replaceAll('-', '+').replaceAll('_', '/')), c => c.charCodeAt(0))
96
+ const bytes = Uint8Array.from(atob(text.replaceAll('-', '+').replaceAll('_', '/')), (c) =>
97
+ c.charCodeAt(0),
98
+ )
79
99
  const list: unknown = JSON.parse(new TextDecoder().decode(bytes))
80
100
  const fits = (v: unknown) => scalar(v) || (stamped(v) && !Number.isNaN(Date.parse(v.$d)))
81
- return Array.isArray(list) && list.length === 2 && list.every(fits) ? [unpack(list[0]), unpack(list[1])] : null
101
+ return Array.isArray(list) && list.length === 2 && list.every(fits)
102
+ ? [unpack(list[0]), unpack(list[1])]
103
+ : null
82
104
  } catch {
83
105
  return null
84
106
  }
@@ -86,38 +108,68 @@ export function decodeCursor(text: string): [unknown, unknown] | null {
86
108
 
87
109
  /** A `query`'s `input`. Takes a `ListRequest` from the client, and hands `run` the `ListParams` a source takes. */
88
110
  export function listInput(spec: ListSpec): StandardSchemaV1<ListRequest | undefined, ListParams> {
89
- const fallback = spec.pageSize?.default ?? 25, max = spec.pageSize?.max ?? 100
90
- const sorts = new Set(spec.sort), filters = new Set(spec.filter)
111
+ const fallback = spec.pageSize?.default ?? 25,
112
+ max = spec.pageSize?.max ?? 100
113
+ const sorts = new Set(spec.sort),
114
+ filters = new Set(spec.filter)
91
115
  const validate = (input: unknown) => {
92
116
  const issues: { message: string; path?: string[] }[] = []
93
117
  const no = (message: string, ...path: string[]) => void issues.push({ message, path })
94
118
  const r = (input ?? {}) as ListRequest
95
- if (typeof r !== 'object' || Array.isArray(r)) return { issues: [{ message: 'a list request is an object' }] }
119
+ if (typeof r !== 'object' || Array.isArray(r))
120
+ return { issues: [{ message: 'a list request is an object' }] }
96
121
  const mode = r.mode ?? (r.cursor !== undefined ? 'cursor' : 'page')
97
122
  if (mode !== 'page' && mode !== 'cursor') no('`mode` is `page` or `cursor`', 'mode')
98
123
  const size = r.pageSize ?? fallback
99
- if (!Number.isInteger(size) || size < 1 || size > max) no(`\`pageSize\` is a whole number up to ${max}`, 'pageSize')
124
+ if (!Number.isInteger(size) || size < 1 || size > max)
125
+ no(`\`pageSize\` is a whole number up to ${max}`, 'pageSize')
100
126
  const page = r.page ?? 1
101
- if (!Number.isInteger(page) || page < 1 || page > 1e6) no('`page` is a whole number from 1', 'page')
127
+ if (!Number.isInteger(page) || page < 1 || page > 1e6)
128
+ no('`page` is a whole number from 1', 'page')
102
129
  let after: ListParams['after'] = null
103
130
  if (r.cursor != null) {
104
131
  after = typeof r.cursor === 'string' ? decodeCursor(r.cursor) : null
105
132
  if (!after) no('that cursor is not one of ours', 'cursor')
106
133
  }
107
134
  const sort = r.sort ?? spec.defaultSort ?? null
108
- if (sort && !(typeof sort === 'object' && sorts.has(sort.field) && (sort.dir === 'asc' || sort.dir === 'desc'))) no('that sort is not allowed', 'sort')
109
- if (r.search !== undefined && (typeof r.search !== 'string' || r.search.length > SEARCH_MAX)) no(`\`search\` is text up to ${SEARCH_MAX} characters`, 'search')
135
+ if (
136
+ sort &&
137
+ !(
138
+ typeof sort === 'object' &&
139
+ sorts.has(sort.field) &&
140
+ (sort.dir === 'asc' || sort.dir === 'desc')
141
+ )
142
+ )
143
+ no('that sort is not allowed', 'sort')
144
+ if (r.search !== undefined && (typeof r.search !== 'string' || r.search.length > SEARCH_MAX))
145
+ no(`\`search\` is text up to ${SEARCH_MAX} characters`, 'search')
110
146
  if (r.search && !spec.search?.length) no('this list has no search', 'search')
111
147
  const filter: ListParams['filter'] = Object.create(null)
112
- if (r.filter !== undefined && (typeof r.filter !== 'object' || r.filter === null || Array.isArray(r.filter))) no('`filter` is an object', 'filter')
113
- else for (const key of Object.keys(r.filter ?? {})) {
114
- const v = r.filter![key]!
115
- if (!filters.has(key)) no(`\`${key}\` can't be filtered on`, 'filter', key)
116
- else if (Array.isArray(v) ? !(v.length && v.length <= IN_MAX && v.every(scalar)) : !scalar(v)) no(`\`${key}\` takes a value or a list of up to ${IN_MAX}, not an object`, 'filter', key)
117
- else filter[key] = v
118
- }
148
+ if (
149
+ r.filter !== undefined &&
150
+ (typeof r.filter !== 'object' || r.filter === null || Array.isArray(r.filter))
151
+ )
152
+ no('`filter` is an object', 'filter')
153
+ else
154
+ for (const key of Object.keys(r.filter ?? {})) {
155
+ const v = r.filter![key]!
156
+ if (!filters.has(key)) no(`\`${key}\` can't be filtered on`, 'filter', key)
157
+ else if (
158
+ Array.isArray(v) ? !(v.length && v.length <= IN_MAX && v.every(scalar)) : !scalar(v)
159
+ )
160
+ no(`\`${key}\` takes a value or a list of up to ${IN_MAX}, not an object`, 'filter', key)
161
+ else filter[key] = v
162
+ }
119
163
  if (issues.length) return { issues }
120
- const params: ListParams = { mode, limit: size, offset: mode === 'page' ? (page - 1) * size : 0, after, sort, filter, ...(r.search ? { search: r.search } : {}) }
164
+ const params: ListParams = {
165
+ mode,
166
+ limit: size,
167
+ offset: mode === 'page' ? (page - 1) * size : 0,
168
+ after,
169
+ sort,
170
+ filter,
171
+ ...(r.search ? { search: r.search } : {}),
172
+ }
121
173
  return { value: params }
122
174
  }
123
175
  return { '~standard': { version: 1, vendor: 'weft', validate } }
@@ -155,7 +207,13 @@ export interface DbAdapter<T = unknown> {
155
207
  source<Row = Record<string, unknown>>(target: T, spec: SourceSpec): ListSource<Row>
156
208
  }
157
209
 
158
- const refuse = (what: string) => new StartError('Q002', `the list source refused ${what}`, 'add it to the source\'s `columns`, and to `listInput`', { status: 400 })
210
+ const refuse = (what: string) =>
211
+ new StartError(
212
+ 'Q002',
213
+ `the list source refused ${what}`,
214
+ "add it to the source's `columns`, and to `listInput`",
215
+ { status: 400 },
216
+ )
159
217
 
160
218
  /** A request checked against the source and put in stored names. Throws on any name outside `columns`. */
161
219
  export interface Plan {
@@ -175,11 +233,20 @@ export function storedId(spec: SourceSpec): string {
175
233
 
176
234
  /** Check a request against `spec`, for a source to run. */
177
235
  export function plan(spec: SourceSpec, p: ListParams): Plan {
178
- const stored = (field: string) => (Object.hasOwn(spec.columns, field) ? spec.columns[field]! : (() => { throw refuse(`\`${field}\``) })())
236
+ const stored = (field: string) =>
237
+ Object.hasOwn(spec.columns, field)
238
+ ? spec.columns[field]!
239
+ : (() => {
240
+ throw refuse(`\`${field}\``)
241
+ })()
179
242
  const id = spec.id ?? 'id'
180
- const where = Object.keys(p.filter).map(field => ({ column: stored(field), values: [p.filter[field]!].flat() }))
181
- if (where.some(w => !w.values.every(scalar))) throw refuse('a filter value that is not a scalar')
182
- const searching = p.search ? spec.search ?? [] : []
243
+ const where = Object.keys(p.filter).map((field) => ({
244
+ column: stored(field),
245
+ values: [p.filter[field]!].flat(),
246
+ }))
247
+ if (where.some((w) => !w.values.every(scalar)))
248
+ throw refuse('a filter value that is not a scalar')
249
+ const searching = p.search ? (spec.search ?? []) : []
183
250
  if (p.search && !searching.length) throw refuse('a search')
184
251
  return {
185
252
  sort: p.sort ? { column: stored(p.sort.field), dir: p.sort.dir, field: p.sort.field } : null,
@@ -192,32 +259,56 @@ export function plan(spec: SourceSpec, p: ListParams): Plan {
192
259
  /** A stored row in public names, with only `columns` in it. */
193
260
  export const rowOf = <Row = Record<string, unknown>>(spec: SourceSpec) => {
194
261
  const pairs = Object.entries(spec.columns)
195
- return (row: object) => Object.fromEntries(pairs.filter(([, c]) => c in row).map(([f, c]) => [f, (row as Record<string, unknown>)[c]])) as Row
262
+ return (row: object) =>
263
+ Object.fromEntries(
264
+ pairs.filter(([, c]) => c in row).map(([f, c]) => [f, (row as Record<string, unknown>)[c]]),
265
+ ) as Row
196
266
  }
197
267
 
198
268
  /** A write in stored names. Refuses anything outside `write`, so a client can't set `role` or move an id. */
199
- export function written(spec: SourceSpec, data: Record<string, unknown>, creating = false): Record<string, unknown> {
269
+ export function written(
270
+ spec: SourceSpec,
271
+ data: Record<string, unknown>,
272
+ creating = false,
273
+ ): Record<string, unknown> {
200
274
  const id = spec.id ?? 'id'
201
- const allowed = new Set(spec.write ?? Object.keys(spec.columns).filter(f => f !== id))
275
+ const allowed = new Set(spec.write ?? Object.keys(spec.columns).filter((f) => f !== id))
202
276
  if (creating) allowed.add(id)
203
277
  const out: Record<string, unknown> = {}
204
278
  for (const field of Object.keys(data)) {
205
- if (!allowed.has(field) || !Object.hasOwn(spec.columns, field)) throw refuse(`a write to \`${field}\``)
279
+ if (!allowed.has(field) || !Object.hasOwn(spec.columns, field))
280
+ throw refuse(`a write to \`${field}\``)
206
281
  out[spec.columns[field]!] = data[field]
207
282
  }
208
283
  return out
209
284
  }
210
285
 
211
286
  /** `rows` are in public names, with one more than `limit` in cursor mode when there is a next page. */
212
- export function pageOf<R>(rows: R[], p: ListParams, field: { sort?: string | undefined; id: string }, total?: number): ListPage<R> {
287
+ export function pageOf<R>(
288
+ rows: R[],
289
+ p: ListParams,
290
+ field: { sort?: string | undefined; id: string },
291
+ total?: number,
292
+ ): ListPage<R> {
213
293
  if (p.mode === 'page') {
214
294
  const page = p.offset / p.limit + 1
215
- return { items: rows, ...(total === undefined ? {} : { total }), ...(total !== undefined && p.offset + p.limit < total ? { next: page + 1 } : {}), ...(page > 1 ? { prev: page - 1 } : {}) }
295
+ return {
296
+ items: rows,
297
+ ...(total === undefined ? {} : { total }),
298
+ ...(total !== undefined && p.offset + p.limit < total ? { next: page + 1 } : {}),
299
+ ...(page > 1 ? { prev: page - 1 } : {}),
300
+ }
216
301
  }
217
302
  const more = rows.length > p.limit
218
303
  const items = more ? rows.slice(0, p.limit) : rows
219
304
  const last = items.at(-1) as R & Record<string, unknown>
220
- return { items, ...(total === undefined ? {} : { total }), ...(more ? { next: encodeCursor(field.sort ? last[field.sort] : last[field.id], last[field.id]) } : {}) }
305
+ return {
306
+ items,
307
+ ...(total === undefined ? {} : { total }),
308
+ ...(more
309
+ ? { next: encodeCursor(field.sort ? last[field.sort] : last[field.id], last[field.id]) }
310
+ : {}),
311
+ }
221
312
  }
222
313
 
223
314
  /** `LIKE` with `!` as the escape: the one character that means the same on SQLite, Postgres and MySQL. */
package/src/listen.ts CHANGED
@@ -8,17 +8,43 @@ import { StartError } from './data'
8
8
  const BEAT = 10_000
9
9
 
10
10
  /** Listens until the returned `stop`. Run one: two listeners poke twice, which costs a refetch and loses nothing. */
11
- export async function listen({ changes, bus, say = console.info, beat = BEAT }: { changes: Source; bus: Bus; say?: (line: string) => void; beat?: number }) {
12
- if (bus.name === 'memory') throw new StartError('R017', '`weft db listen` publishes to the bus, and the memory bus stops at this process', 'set `bus` in `src/realtime.server.ts`')
11
+ export async function listen({
12
+ changes,
13
+ bus,
14
+ say = console.info,
15
+ beat = BEAT,
16
+ }: {
17
+ changes: Source
18
+ bus: Bus
19
+ say?: (line: string) => void
20
+ beat?: number
21
+ }) {
22
+ if (bus.name === 'memory')
23
+ throw new StartError(
24
+ 'R017',
25
+ '`weft db listen` publishes to the bus, and the memory bus stops at this process',
26
+ 'set `bus` in `src/realtime.server.ts`',
27
+ )
13
28
  const how = await changes.probe()
14
29
  say(`weft live: ${how.mode} (${how.via})${how.why ? `: ${how.why}` : ''}`)
15
- if (how.mode !== 'feed') throw new StartError('R015', `this listener has no real feed${how.why ? `: ${how.why}` : ''}`, how.fix)
30
+ if (how.mode !== 'feed')
31
+ throw new StartError(
32
+ 'R015',
33
+ `this listener has no real feed${how.why ? `: ${how.why}` : ''}`,
34
+ how.fix,
35
+ )
16
36
  const feed = createFeed(changes, {
17
- emit: p => void bus.publish('weft:poke', JSON.stringify(p)).catch(e => say(`weft live: publish failed: ${e?.message ?? e}`)),
18
- status: down => say(down ? `weft live: down: ${down}` : 'weft live: back'),
37
+ emit: (p) =>
38
+ void bus
39
+ .publish('weft:poke', JSON.stringify(p))
40
+ .catch((e) => say(`weft live: publish failed: ${e?.message ?? e}`)),
41
+ status: (down) => say(down ? `weft live: down: ${down}` : 'weft live: back'),
19
42
  })
20
43
  await feed.start(how)
21
- const beats = setInterval(() => void bus.publish('weft:poke', '{"v":1,"b":1}').catch(() => {}), beat)
44
+ const beats = setInterval(
45
+ () => void bus.publish('weft:poke', '{"v":1,"b":1}').catch(() => {}),
46
+ beat,
47
+ )
22
48
  await bus.publish('weft:poke', '{"v":1,"b":1}')
23
49
  return async () => {
24
50
  clearInterval(beats)
@@ -0,0 +1,18 @@
1
+ import type { LiveApi } from '../data'
2
+
3
+ // `data.ts` hands these over (`use`). A static import would close a cycle with its `import('./live')`, and the bundler
4
+ // would then keep this chunk, the hub and the split they force in apps that never read `.live`.
5
+ export let cache: LiveApi['cache'],
6
+ currentScope: LiveApi['currentScope'],
7
+ fns: LiveApi['fns'],
8
+ FnError: LiveApi['FnError'],
9
+ keyOf: LiveApi['keyOf'],
10
+ net: LiveApi['net'],
11
+ rt: LiveApi['rt'],
12
+ runtime: LiveApi['runtime'],
13
+ skip: LiveApi['skip'],
14
+ StartError: LiveApi['StartError'],
15
+ track: LiveApi['track']
16
+ export const use = (api: LiveApi) =>
17
+ void ({ cache, currentScope, fns, FnError, keyOf, net, rt, runtime, skip, StartError, track } =
18
+ api)
@@ -0,0 +1,6 @@
1
+ const BASE = 500
2
+ export const CAP = 30_000
3
+
4
+ /** Full jitter: anywhere from 0 to 0.5 s, 1 s, 2 s … 30 s. */
5
+ export const backoff = (attempt: number, random = Math.random) =>
6
+ random() * Math.min(CAP, BASE * 2 ** attempt)
@@ -0,0 +1,42 @@
1
+ import { DEV } from '@weft-js/core'
2
+ import { cache, FnError } from './api'
3
+ import type { Sub } from './sub-types'
4
+
5
+ export type Row = { 0: number; 1: never; 2: unknown }
6
+
7
+ export function ack(d: Row, byN: Map<number, Sub>, seeded: () => boolean) {
8
+ const s = byN.get(d[0])
9
+ if (!s) return
10
+ s.tags = d[1]
11
+ s.error.value = undefined
12
+ if (DEV && !s.tags.length)
13
+ console.warn(
14
+ `weft: W053 \`${s.fn.$id}\` is read with \`.live()\` but declares no \`tags\`: no mutation can ever poke it.\n fix: add \`tags\` to the query`,
15
+ )
16
+ // Data older than the subscription may have missed a write. A page's first connect is covered by its cursor.
17
+ if (!s.acked && !seeded() && s.tags.length) void cache.invalidate(...s.tags)
18
+ s.acked = true
19
+ }
20
+
21
+ export function update(event: string, d: Row, byN: Map<number, Sub>) {
22
+ if (event === 'm') {
23
+ const s = byN.get(d[0])
24
+ if (!s) return
25
+ if (d[1] !== null) s.cursor = d[1]
26
+ s.error.value = undefined
27
+ for (const v of s.views.values())
28
+ v.box.value = v.fold ? v.fold(v.box.value as never, d[2] as never) : d[2]
29
+ } else if (event === 'r') {
30
+ const s = byN.get(d[0])
31
+ if (!s) return
32
+ s.cursor = undefined
33
+ for (const v of s.views.values()) v.box.value = v.initial
34
+ } else if (event === 'f') {
35
+ const s = byN.get(d[0])
36
+ if (s) s.done.value = true
37
+ } else if (event === 'e') {
38
+ const s = byN.get(d[0])
39
+ if (s)
40
+ s.error.value = new FnError(d[1], (d[1] as { code?: string }).code === 'A002' ? 401 : 500)
41
+ }
42
+ }
@@ -0,0 +1,77 @@
1
+ import {
2
+ DEV,
3
+ createRoot,
4
+ derived,
5
+ effect,
6
+ getOwner,
7
+ onCleanup,
8
+ state,
9
+ untrack,
10
+ } from '@weft-js/core'
11
+ import type { Fn } from '../data'
12
+ import { currentScope, rt, skip, StartError } from './api'
13
+ import type { Sub, View } from './sub-types'
14
+ import type { Stream, StreamOptions } from './types'
15
+
16
+ export function read(
17
+ fn: Fn,
18
+ input: unknown,
19
+ o: StreamOptions<never, unknown> = {},
20
+ acquire: (fn: Fn, input: unknown) => Sub,
21
+ held: (s: Sub, keep?: string | number) => () => void,
22
+ ): Stream<unknown> {
23
+ if (!getOwner())
24
+ throw new StartError(
25
+ 'W051',
26
+ DEV
27
+ ? `\`${fn.$id}()\` (a stream) was called outside a component or scope: nothing would close it`
28
+ : fn.$id,
29
+ DEV
30
+ ? 'call it in a component, a `defineState` setup or `createRoot(dispose => …)`'
31
+ : undefined,
32
+ )
33
+ const scope = currentScope()
34
+ if (scope) {
35
+ scope.live = true // the server renders `initial`, and the client hydrates to the same
36
+ return { value: o.initial, status: rt.status, error: undefined }
37
+ }
38
+ const view = state<{ s: Sub; v: View } | undefined>(undefined)
39
+ const fold = o.fold ? String(o.fold) : ''
40
+ effect(
41
+ () => {
42
+ const arg = typeof input === 'function' ? (input as () => unknown)() : input
43
+ if (arg === skip) return undefined
44
+ return untrack(() => {
45
+ const s = acquire(fn, arg)
46
+ if (o.fold) s.buffer ??= o.buffer ?? 256
47
+ else if (o.buffer) s.buffer ??= o.buffer
48
+ let v = s.views.get(fold)
49
+ if (!v)
50
+ s.views.set(
51
+ fold,
52
+ (v = createRoot(
53
+ () => ({ fold: o.fold, initial: o.initial, box: state<unknown>(o.initial) }),
54
+ null,
55
+ )),
56
+ )
57
+ v.fold = o.fold
58
+ onCleanup(held(s, o.keep))
59
+ return { s, v }
60
+ })
61
+ },
62
+ (now) => {
63
+ view.value = now
64
+ },
65
+ )
66
+ const status = derived(() => (view.value?.s.done.value ? 'done' : rt.status.value))
67
+ return {
68
+ get value() {
69
+ const now = view.value
70
+ return now ? now.v.box.value : o.initial
71
+ },
72
+ status,
73
+ get error() {
74
+ return view.value?.s.error.value
75
+ },
76
+ }
77
+ }
@@ -0,0 +1,24 @@
1
+ import type { State } from '@weft-js/core'
2
+ import type { Fn } from '../data'
3
+
4
+ export interface View {
5
+ fold?: ((acc: never, m: never) => unknown) | undefined
6
+ initial: unknown
7
+ box: State<unknown>
8
+ }
9
+ export interface Sub {
10
+ n: number
11
+ key: string
12
+ fn: Fn
13
+ input: unknown
14
+ refs: number
15
+ timer?: ReturnType<typeof setTimeout> | undefined
16
+ /** Stream: the last id, sent back on a resume. Query: its tags, from the ack. */
17
+ cursor?: string | undefined
18
+ tags: string[]
19
+ buffer?: number
20
+ acked: boolean
21
+ views: Map<string, View>
22
+ error: State<unknown>
23
+ done: State<boolean>
24
+ }