@weft-js/start 0.0.0-stage → 0.1.0

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 (75) hide show
  1. package/LICENSE +16 -0
  2. package/README.md +41 -2
  3. package/package.json +107 -4
  4. package/src/app.mjs +82 -0
  5. package/src/auth/better-auth.ts +58 -0
  6. package/src/auth/drizzle.ts +17 -0
  7. package/src/auth.ts +47 -0
  8. package/src/boot.ts +44 -0
  9. package/src/builtin.tsx +19 -0
  10. package/src/bus/postgres.ts +91 -0
  11. package/src/bus.ts +48 -0
  12. package/src/capture.d.mts +1 -0
  13. package/src/capture.mjs +15 -0
  14. package/src/changes-sql.d.mts +14 -0
  15. package/src/changes-sql.mjs +99 -0
  16. package/src/changes.ts +253 -0
  17. package/src/channel-fake.ts +55 -0
  18. package/src/channel.ts +616 -0
  19. package/src/client.tsx +20 -0
  20. package/src/contract.mjs +308 -0
  21. package/src/data.ts +929 -0
  22. package/src/db/bun-sqlite.d.ts +10 -0
  23. package/src/db/changes-mysql.ts +86 -0
  24. package/src/db/changes-postgres.ts +159 -0
  25. package/src/db/changes-sqlite.ts +116 -0
  26. package/src/db/core.ts +63 -0
  27. package/src/db/index.ts +27 -0
  28. package/src/db/mysql.ts +67 -0
  29. package/src/db/postgres.ts +65 -0
  30. package/src/db/sqlite.ts +139 -0
  31. package/src/derive.d.mts +16 -0
  32. package/src/derive.mjs +140 -0
  33. package/src/dev-runtime.mjs +42 -0
  34. package/src/exec.ts +519 -0
  35. package/src/extension.ts +38 -0
  36. package/src/first-paint.ts +57 -0
  37. package/src/form.tsx +134 -0
  38. package/src/gate.css +6 -0
  39. package/src/hub.ts +822 -0
  40. package/src/index.ts +106 -0
  41. package/src/inline.mjs +2 -0
  42. package/src/inspect.mjs +280 -0
  43. package/src/link.ts +456 -0
  44. package/src/list-drizzle.ts +50 -0
  45. package/src/list.ts +224 -0
  46. package/src/listen.ts +28 -0
  47. package/src/live-fake.ts +42 -0
  48. package/src/live-target.mjs +144 -0
  49. package/src/live.ts +499 -0
  50. package/src/media.ts +43 -0
  51. package/src/migrations.mjs +19 -0
  52. package/src/mux-session.ts +6 -0
  53. package/src/mux.ts +135 -0
  54. package/src/og/DefaultCard.tsx +60 -0
  55. package/src/og/fonts.mjs +64 -0
  56. package/src/og/images.mjs +54 -0
  57. package/src/og/index.mjs +146 -0
  58. package/src/og/native.mjs +29 -0
  59. package/src/range.mjs +19 -0
  60. package/src/router.ts +149 -0
  61. package/src/routes.mjs +142 -0
  62. package/src/run.tsx +198 -0
  63. package/src/rust.ts +333 -0
  64. package/src/server.tsx +1014 -0
  65. package/src/shape.d.mts +3 -0
  66. package/src/shape.mjs +73 -0
  67. package/src/share-server.ts +208 -0
  68. package/src/share.ts +503 -0
  69. package/src/sheets.mjs +75 -0
  70. package/src/sidecar.mjs +503 -0
  71. package/src/socket.ts +389 -0
  72. package/src/stream.ts +13 -0
  73. package/src/verify.ts +251 -0
  74. package/src/view.tsx +47 -0
  75. package/src/vite.mjs +899 -0
package/src/live.ts ADDED
@@ -0,0 +1,499 @@
1
+ // The realtime client (research 30 §6): one connection per tab, a line on the browser's one socket (link.ts), one subscription per function + input, shared by every
2
+ // reader and owned by this module, not by a component (PLAN §4.2), so an unmounting tab loses neither its subscription
3
+ // nor its folded state for `keep`. `q.live()` imports this on its first call; `stream` through `stream.ts`, so an app
4
+ // that uses neither ships none of it.
5
+ //
6
+ // A poke is a local `cache.invalidate`: readers refetch through `query`, and the keyed list writes what changed.
7
+
8
+ import { DEV, batch, createRoot, derived, effect, getOwner, onCleanup, state, untrack, type Derived, type State } from '@weft-js/core'
9
+ import type { AuthScope, Context, Fn, LiveApi, LiveMode, LiveOptions, LiveStatus, Skip, StandardSchemaV1 } from './data'
10
+ import type { Body, SubEntry, Tracked } from './hub'
11
+ import { HANDOVER, noSockets, openLine, type Line } from './link'
12
+
13
+ // `data.ts` hands these over (`use`). A static import would close a cycle with its `import('./live')`, and the bundler
14
+ // would then keep this chunk, the hub and the split they force in apps that never read `.live`.
15
+ let cache: LiveApi['cache'], currentScope: LiveApi['currentScope'], fns: LiveApi['fns'], FnError: LiveApi['FnError'], keyOf: LiveApi['keyOf'], net: LiveApi['net'], rt: LiveApi['rt'], runtime: LiveApi['runtime'], skip: LiveApi['skip'], StartError: LiveApi['StartError'], track: LiveApi['track']
16
+ export const use = (api: LiveApi) => void ({ cache, currentScope, fns, FnError, keyOf, net, rt, runtime, skip, StartError, track } = api)
17
+
18
+ // ---- definitions ----------------------------------------------------------------------------------------------------
19
+
20
+ type In<S> = S extends StandardSchemaV1<infer I, unknown> ? I : void
21
+ type Out<S> = S extends StandardSchemaV1<unknown, infer O> ? O : undefined
22
+
23
+ /** Where a stream stands; `done` is the end of a generator. */
24
+ export type StreamStatus = LiveStatus | 'done'
25
+
26
+ /** `stream()`: the input, the message schema and the generator. */
27
+ export interface StreamDef<S, M, A = undefined> {
28
+ input?: S
29
+ /** Checked in dev and tests (C002), and the contract's `message`. */
30
+ message?: StandardSchemaV1<unknown, M>
31
+ auth?: A
32
+ /** `lastEventId`: the last `tracked` id the client saw, on a resume. Stop when `signal` aborts. */
33
+ run: (args: { input: Out<S>; ctx: Context; signal: AbortSignal; lastEventId?: string }) => AsyncIterable<M | Tracked<M>>
34
+ $id?: string
35
+ }
36
+
37
+ /** `.live()` on a stream: what the value starts as, and how messages fold into it. */
38
+ export interface StreamOptions<M, T> extends LiveOptions {
39
+ /** Keep every message: `fold(acc, message)`. Its state lives in the cache, so a remount finds it. */
40
+ fold?: (acc: T, message: M) => T
41
+ initial?: T
42
+ /** Messages the server holds for a slow client. A fold defaults to 256; without one, the latest wins. */
43
+ buffer?: number
44
+ }
45
+
46
+ /** A stream as a page reads it. */
47
+ export interface Stream<T> {
48
+ /** The latest message, or the fold. */
49
+ readonly value: T
50
+ readonly status: Derived<StreamStatus>
51
+ readonly error: unknown
52
+ }
53
+
54
+ /** What `stream()` returns. */
55
+ export interface StreamFn<I, M> {
56
+ (input: Arg<I>): Stream<M | undefined>
57
+ <T>(input: Arg<I>, options: StreamOptions<M, T> & { initial: T }): Stream<T>
58
+ (input: Arg<I>, options: StreamOptions<M, M>): Stream<M | undefined>
59
+ readonly kind: 'stream'
60
+ readonly $id: string
61
+ }
62
+ type Arg<I> = I | (() => I | Skip)
63
+
64
+ const FN = Symbol.for('weft.fn')
65
+ let auto = 0
66
+
67
+ export function stream<M, S extends StandardSchemaV1 | undefined = undefined, const A extends AuthScope | undefined = undefined>(def: StreamDef<S, M, A>): StreamFn<In<S>, M> {
68
+ const id = def.$id ?? `s${++auto}`
69
+ const fn: Fn = { kind: 'stream', $id: id, def: def as unknown as Fn['def'] }
70
+ fns.set(id, fn)
71
+ return Object.assign((input: unknown, options?: StreamOptions<never, unknown>) => read(fn, input, options), { kind: 'stream', $id: id, [FN]: fn }) as unknown as StreamFn<In<S>, M>
72
+ }
73
+
74
+ // ---- subscriptions --------------------------------------------------------------------------------------------------
75
+
76
+ interface View { fold?: ((acc: never, m: never) => unknown) | undefined; initial: unknown; box: State<unknown> }
77
+ interface Sub {
78
+ n: number
79
+ key: string
80
+ fn: Fn
81
+ input: unknown
82
+ refs: number
83
+ timer?: ReturnType<typeof setTimeout> | undefined
84
+ /** Stream: the last id, sent back on a resume. Query: its tags, from the ack. */
85
+ cursor?: string | undefined
86
+ tags: string[]
87
+ buffer?: number
88
+ acked: boolean
89
+ views: Map<string, View>
90
+ error: State<unknown>
91
+ done: State<boolean>
92
+ }
93
+
94
+ const subs = new Map<string, Sub>()
95
+ const byN = new Map<number, Sub>()
96
+ let nth = 0
97
+
98
+ const KEEP = 30_000
99
+ const ms = (keep: string | number = KEEP): number => {
100
+ if (typeof keep === 'number') return keep
101
+ const m = /^(\d+)(ms|s|m)$/.exec(keep)
102
+ return m ? +m[1]! * { ms: 1, s: 1000, m: 60_000 }[m[2] as 'ms' | 's' | 'm'] : Infinity // 'session'
103
+ }
104
+
105
+ function acquire(fn: Fn, input: unknown): Sub {
106
+ const key = keyOf(fn.$id, input)
107
+ let s = subs.get(key)
108
+ if (!s) {
109
+ s = createRoot(() => ({ n: ++nth, key, fn, input, refs: 0, tags: [], acked: false, views: new Map(), error: state<unknown>(undefined), done: state(false) }), null)
110
+ // Not at the top level: a side effect there would keep this chunk in apps that never read `.live`.
111
+ if (DEV && net() && !net()!.resets.includes(reset)) net()!.resets.push(reset)
112
+ subs.set(key, s)
113
+ byN.set(s.n, s)
114
+ sync()
115
+ }
116
+ clearTimeout(s.timer)
117
+ s.timer = undefined
118
+ s.refs++
119
+ return s
120
+ }
121
+
122
+ function release(s: Sub, keep?: string | number) {
123
+ if (--s.refs) return
124
+ const t = ms(keep)
125
+ if (t === Infinity) return
126
+ if (t) s.timer = setTimeout(() => drop(s), t)
127
+ else drop(s)
128
+ }
129
+
130
+ function drop(s: Sub) {
131
+ subs.delete(s.key)
132
+ byN.delete(s.n)
133
+ sync()
134
+ }
135
+
136
+ /** A reader in the owner of `hold`'s caller. T012 sees it until it lets go. */
137
+ function held(s: Sub, keep?: string | number): () => void {
138
+ const token = {}
139
+ const stop = () => {
140
+ net()?.open.delete(token)
141
+ release(s, keep)
142
+ }
143
+ if (DEV) net()?.open.set(token, [`live subscription \`${s.key}\``, stop])
144
+ return stop
145
+ }
146
+
147
+ /** `q.live()`'s subscription, from data.ts. */
148
+ export function hold(fn: Fn, input: unknown, options?: LiveOptions): () => void {
149
+ return held(acquire(fn, input), options?.keep)
150
+ }
151
+
152
+ function read(fn: Fn, input: unknown, o: StreamOptions<never, unknown> = {}): Stream<unknown> {
153
+ if (!getOwner())
154
+ throw new StartError('W051', DEV ? `\`${fn.$id}()\` (a stream) was called outside a component or scope: nothing would close it` : fn.$id, DEV ? 'call it in a component, a `defineState` setup or `createRoot(dispose => …)`' : undefined)
155
+ const scope = currentScope()
156
+ if (scope) {
157
+ scope.live = true // the server renders `initial`, and the client hydrates to the same
158
+ return { value: o.initial, status: rt.status, error: undefined }
159
+ }
160
+ const view = state<{ s: Sub; v: View } | undefined>(undefined)
161
+ const fold = o.fold ? String(o.fold) : ''
162
+ effect(() => {
163
+ const arg = typeof input === 'function' ? (input as () => unknown)() : input
164
+ if (arg === skip) return undefined
165
+ return untrack(() => {
166
+ const s = acquire(fn, arg)
167
+ if (o.fold) s.buffer ??= o.buffer ?? 256
168
+ else if (o.buffer) s.buffer ??= o.buffer
169
+ let v = s.views.get(fold)
170
+ if (!v) s.views.set(fold, (v = createRoot(() => ({ fold: o.fold, initial: o.initial, box: state<unknown>(o.initial) }), null)))
171
+ v.fold = o.fold
172
+ onCleanup(held(s, o.keep))
173
+ return { s, v }
174
+ })
175
+ }, now => { view.value = now })
176
+ const status = derived(() => (view.value?.s.done.value ? 'done' : rt.status.value))
177
+ return {
178
+ get value() { const now = view.value; return now ? now.v.box.value : o.initial },
179
+ status,
180
+ get error() { return view.value?.s.error.value },
181
+ }
182
+ }
183
+
184
+ // ---- the connection ---------------------------------------------------------------------------------------------------
185
+
186
+ /** How the client reaches `/_weft/rt`: SSE, a shared socket, or a fake under test. */
187
+ export interface Link {
188
+ /** Resolves when the connection ends. */
189
+ open(body: Body, frame: (event: string, data: unknown) => void, signal: AbortSignal, alive: () => void): Promise<void>
190
+ change(body: Body): Promise<void>
191
+ }
192
+
193
+ const post = (body: Body, signal?: AbortSignal) =>
194
+ fetch('/_weft/rt', { method: 'POST', headers: { 'content-type': 'application/json', accept: 'text/event-stream', 'x-weft': '1' }, body: JSON.stringify(body), ...(signal && { signal }) })
195
+
196
+ const http: Link = {
197
+ async open(body, heard, signal, alive) {
198
+ const res = await post(body, signal)
199
+ if (!res.ok) {
200
+ const { e } = await res.json().catch(() => ({ e: {} }))
201
+ throw new FnError(e, res.status)
202
+ }
203
+ const reader = res.body!.getReader()
204
+ const decoder = new TextDecoder()
205
+ let buf = ''
206
+ for (;;) {
207
+ const { done, value } = await reader.read()
208
+ if (done) return
209
+ alive()
210
+ buf += decoder.decode(value, { stream: true })
211
+ for (let i; (i = buf.indexOf('\n\n')) >= 0; buf = buf.slice(i + 2)) {
212
+ if (buf[0] === ':') continue
213
+ const line = buf.indexOf('\n')
214
+ heard(buf.slice(7, line), JSON.parse(buf.slice(line + 7, i)))
215
+ }
216
+ }
217
+ },
218
+ async change(body) {
219
+ const res = await post(body)
220
+ if (!res.ok) throw new FnError({}, res.status)
221
+ },
222
+ }
223
+
224
+ /** A line on the browser's one socket (link.ts): the same events, and `/_weft/rt`'s SSE is for a host with no sockets. */
225
+ let line: Line | undefined
226
+ const socket: Link = {
227
+ open(body, heard, signal, alive) {
228
+ return new Promise<void>((done, fail) => {
229
+ const l = (line = openLine('r', JSON.stringify(body), text => {
230
+ alive()
231
+ const at = text.indexOf(':'), event = text.slice(0, at)
232
+ if (event !== 'ping') heard(event, JSON.parse(text.slice(at + 1)))
233
+ }, (code, reason) => {
234
+ if (line === l) line = undefined
235
+ if (code === HANDOVER) retry = 0
236
+ if (code === 4000 && reason) fail(new FnError(JSON.parse(reason), 429))
237
+ else done()
238
+ }))
239
+ signal.addEventListener('abort', () => l.close())
240
+ })
241
+ },
242
+ change(body) {
243
+ if (!line) return Promise.reject(new FnError({}, 503))
244
+ line.send(JSON.stringify(body))
245
+ return Promise.resolve()
246
+ },
247
+ }
248
+
249
+ /**
250
+ * @weft-js/core/test only (`live-fake.ts`). The specifier hides it from the bundler: an `import()` it can see would keep the
251
+ * fake, and through it the hub, as chunks of every app, used or not.
252
+ */
253
+ const FAKE = './live-fake.ts'
254
+ const fake: Link = {
255
+ open(body, heard, signal) {
256
+ let up!: () => void
257
+ void track(new Promise<void>(ok => { up = ok })) // `net.settle()` waits for the connection, not for its end
258
+ return import(/* @vite-ignore */ FAKE).then(m => m.open(body, heard, signal, up, subs, (close: () => unknown) => (closeHub = close))).finally(up)
259
+ },
260
+ change: async body => (await import(/* @vite-ignore */ FAKE)).change(body),
261
+ }
262
+
263
+ /** Two sockets that never answered, and this host has none. */
264
+ const link = (): Link => (DEV && net() ? fake : noSockets() ? http : socket)
265
+ let via: Link = socket
266
+ let closeHub: (() => unknown) | undefined
267
+
268
+ const BASE = 500, CAP = 30_000, DEAD = 45_000, HIDDEN = 300_000
269
+
270
+ /** Full jitter: anywhere from 0 to 0.5 s, 1 s, 2 s … 30 s. */
271
+ export const backoff = (attempt: number, random = Math.random) => random() * Math.min(CAP, BASE * 2 ** attempt)
272
+
273
+ interface Live { ac: AbortController; k?: string; sent: Set<number> }
274
+ let conn: Live | undefined
275
+ let running = false
276
+ let attempt = 0
277
+ /** The poke cursor. Seeded by the page, it covers what was published between the render and this connection. */
278
+ let cursor: string | undefined
279
+ let build: string | undefined
280
+ let seeded = false
281
+ let retry: number | undefined
282
+ let nudge: (() => void) | undefined
283
+ let hiddenAt: ReturnType<typeof setTimeout> | undefined
284
+ let parked = false
285
+ let changing: Promise<void> | undefined
286
+ let pokes = new Set<string>()
287
+ let pokeTimer: ReturnType<typeof setTimeout> | undefined
288
+
289
+ /** The page's `#weft-rt`, on the tab's first connect. */
290
+ function readPage() {
291
+ const el = document.getElementById('weft-rt')
292
+ if (!el) return
293
+ const page = JSON.parse(el.textContent) as { c?: string; b?: string }
294
+ el.remove()
295
+ cursor = page.c
296
+ build = page.b
297
+ seeded = !!cursor
298
+ }
299
+
300
+ const entry = (s: Sub): SubEntry => (s.fn.kind === 'query' ? [s.n, 'q', s.fn.$id, s.input] : [s.n, 's', s.fn.$id, s.input, s.cursor ?? '', s.buffer ?? 0])
301
+ const set = (status: LiveStatus) => { rt.status.value = status }
302
+ interface Linked { m: LiveMode; d?: 1 }
303
+ /** What's positional in a frame: `[subscription, ...]`. What the rest of the fields mean goes by the event. */
304
+ type Row = { 0: number; 1: never; 2: unknown }
305
+
306
+ /** The connection is up: how live that is depends on the server's feed. */
307
+ function linked(l?: Linked) {
308
+ rt.mode.value = l?.m ?? 'app'
309
+ set(l?.d ? 'reconnecting' : l?.m === 'poll' || l?.m === 'off' ? 'degraded' : 'live')
310
+ if (DEV && l && l.m !== 'feed' && l.m !== 'external' && !warned) {
311
+ warned = true
312
+ console.warn(`weft: R016 live queries are \`degraded\` (mode \`${l.m}\`): writes from outside the app arrive late or not at all. The server log says why.`)
313
+ }
314
+ }
315
+ let warned = false
316
+ const offline = () => ! navigator.onLine
317
+
318
+ function flushPokes() {
319
+ const tags = [...pokes]
320
+ pokes = new Set()
321
+ pokeTimer = undefined
322
+ void cache.invalidate(...tags)
323
+ }
324
+
325
+ function frame(event: string, data: unknown) {
326
+ const d = data as Row & Record<'b' | 'k' | 'c', string> & { l?: Linked; t: string[]; retry: number }
327
+ batch(() => {
328
+ if (event === 'h') {
329
+ if (build && d.b && d.b !== build) rt.stale.value = true
330
+ build = d.b
331
+ if (conn) conn.k = runtime.conn = d.k
332
+ cursor ??= d.c
333
+ attempt = 0
334
+ linked(d.l)
335
+ sync() // what changed while connecting
336
+ } else if (event === 'a') {
337
+ const s = byN.get(d[0])
338
+ if (!s) return
339
+ s.tags = d[1]
340
+ s.error.value = undefined
341
+ if (DEV && !s.tags.length) console.warn(`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`)
342
+ // Data older than the subscription may have missed a write. A page's first connect is covered by its cursor.
343
+ if (!s.acked && !seeded && s.tags.length) void cache.invalidate(...s.tags)
344
+ s.acked = true
345
+ } else if (event === 'p') {
346
+ cursor = d.c
347
+ for (const t of d.t) pokes.add(t)
348
+ if (net()) flushPokes()
349
+ else pokeTimer ??= setTimeout(flushPokes, 50 + Math.random() * 250)
350
+ } else if (event === 'R') {
351
+ cursor = d.c
352
+ void cache.invalidate(...[...subs.values()].flatMap(s => s.tags))
353
+ } else if (event === 'm') {
354
+ const s = byN.get(d[0])
355
+ if (!s) return
356
+ if (d[1] !== null) s.cursor = d[1]
357
+ s.error.value = undefined
358
+ for (const v of s.views.values()) v.box.value = v.fold ? v.fold(v.box.value as never, d[2] as never) : d[2]
359
+ } else if (event === 'r') {
360
+ const s = byN.get(d[0])
361
+ if (!s) return
362
+ s.cursor = undefined
363
+ for (const v of s.views.values()) v.box.value = v.initial
364
+ } else if (event === 'f') {
365
+ const s = byN.get(d[0])
366
+ if (s) s.done.value = true
367
+ } else if (event === 'e') {
368
+ const s = byN.get(d[0])
369
+ if (s) s.error.value = new FnError(d[1], (d[1] as { code?: string }).code === 'A002' ? 401 : 500)
370
+ } else if (event === 'L') linked(d as unknown as Linked)
371
+ else if (event === 'b') retry = d.retry
372
+ })
373
+ }
374
+
375
+ /** Woken early by `online`, by the tab showing again and by a change to the set. */
376
+ const sleep = (t: number) => new Promise<void>(ok => {
377
+ const timer = setTimeout(ok, t)
378
+ nudge = () => {
379
+ clearTimeout(timer)
380
+ nudge = undefined
381
+ ok()
382
+ }
383
+ })
384
+
385
+ async function run() {
386
+ running = true
387
+ listen(true)
388
+ readPage()
389
+ while (subs.size) {
390
+ if (parked || offline()) {
391
+ set(parked ? 'reconnecting' : 'offline')
392
+ await sleep(CAP)
393
+ continue
394
+ }
395
+ const ac = new AbortController()
396
+ conn = { ac, sent: new Set(byN.keys()) }
397
+ let dead: ReturnType<typeof setTimeout> | undefined
398
+ const alive = () => {
399
+ clearTimeout(dead)
400
+ if (!net()) dead = setTimeout(() => ac.abort(), DEAD)
401
+ }
402
+ alive()
403
+ retry = undefined
404
+ try {
405
+ await (via = link()).open({ c: cursor, s: [...subs.values()].filter(s => !s.done.value).map(entry) }, frame, ac.signal, alive)
406
+ } catch (e) {
407
+ if (!ac.signal.aborted && e instanceof FnError) for (const s of subs.values()) untrack(() => { s.error.value = e })
408
+ }
409
+ clearTimeout(dead)
410
+ conn = undefined
411
+ runtime.conn = undefined
412
+ seeded = false
413
+ if (!subs.size) break
414
+ const n = net()
415
+ if (n) n.reconnects++
416
+ set(offline() ? 'offline' : 'reconnecting')
417
+ if (!n) await sleep(retry ?? backoff(attempt++))
418
+ }
419
+ running = false
420
+ attempt = 0
421
+ listen(false)
422
+ set('connecting')
423
+ }
424
+
425
+ /** Bring the server's set up to date with `subs`, in place. A 404 (another instance, a restart) reconnects. */
426
+ function sync() {
427
+ if (changing) return
428
+ changing = Promise.resolve().then(async () => {
429
+ changing = undefined
430
+ if (!subs.size) return conn?.ac.abort()
431
+ if (!running) return void run()
432
+ const live = conn
433
+ if (!live?.k) return
434
+ const add = [...byN.values()].filter(s => !live.sent.has(s.n))
435
+ const gone = [...live.sent].filter(n => !byN.has(n))
436
+ if (!add.length && !gone.length) return
437
+ for (const s of add) live.sent.add(s.n)
438
+ for (const n of gone) live.sent.delete(n)
439
+ try {
440
+ await via.change({ k: live.k, s: add.map(entry), d: gone })
441
+ } catch {
442
+ live.ac.abort()
443
+ }
444
+ })
445
+ void track(changing)
446
+ }
447
+
448
+ // ---- the page -------------------------------------------------------------------------------------------------------
449
+
450
+ const wake = () => {
451
+ parked = false
452
+ nudge?.()
453
+ }
454
+
455
+ function park() {
456
+ parked = true
457
+ conn?.ac.abort()
458
+ }
459
+
460
+ function onOffline() {
461
+ set('offline')
462
+ conn?.ac.abort()
463
+ }
464
+
465
+ function onVisibility() {
466
+ clearTimeout(hiddenAt)
467
+ if (document.visibilityState === 'hidden') hiddenAt = setTimeout(park, HIDDEN)
468
+ else wake()
469
+ }
470
+
471
+ function listen(on: boolean) {
472
+ if (typeof window === 'undefined') return
473
+ const m = on ? 'addEventListener' : 'removeEventListener'
474
+ window[m]('online', wake)
475
+ window[m]('offline', onOffline)
476
+ window[m]('pagehide', park)
477
+ window[m]('pageshow', wake)
478
+ document[m]('visibilitychange', onVisibility)
479
+ if (!on) clearTimeout(hiddenAt)
480
+ }
481
+
482
+ /** @weft-js/core/test, after each test. */
483
+ function reset() {
484
+ for (const s of subs.values()) clearTimeout(s.timer)
485
+ subs.clear()
486
+ byN.clear()
487
+ conn?.ac.abort()
488
+ nudge?.()
489
+ clearTimeout(pokeTimer)
490
+ pokes = new Set()
491
+ cursor = build = retry = undefined
492
+ seeded = parked = false
493
+ attempt = 0
494
+ const n = net()!
495
+ n.reconnects = 0
496
+ n.rt = undefined
497
+ void closeHub?.()
498
+ untrack(() => { rt.stale.value = false; rt.status.value = 'connecting' })
499
+ }
package/src/media.ts ADDED
@@ -0,0 +1,43 @@
1
+ // `z.media()` (research 29 §6): the record of an upload or a CMS image, in the shape `<Media src>` already takes, so
2
+ // `<Media src={post.cover} alt="…" />` needs no conversion. Zod's `z` is an ES namespace and can't be extended, so this
3
+ // `z` is Zod's API plus `media()`.
4
+
5
+ import { z as zod } from 'zod'
6
+ import type { MediaAsset } from '@weft-js/core/media'
7
+
8
+ const record = () =>
9
+ zod.object({
10
+ kind: zod.enum(['photo', 'graphic', 'vector', 'video', 'audio']),
11
+ src: zod.string(),
12
+ width: zod.number().int().nonnegative(),
13
+ height: zod.number().int().nonnegative(),
14
+ type: zod.string(),
15
+ sources: zod.array(zod.object({ type: zod.string(), srcset: zod.string() })),
16
+ dominant: zod.string().optional(),
17
+ placeholder: zod.string().optional(),
18
+ name: zod.string(),
19
+ quality: zod.enum(['ssimulacra2', 'fixed', 'original']).optional(),
20
+ poster: zod.string().optional(),
21
+ duration: zod.number().optional(),
22
+ audio: zod.boolean().optional(),
23
+ animated: zod.boolean().optional(),
24
+ loop: zod.number().optional(),
25
+ copy: zod.object({ src: zod.string(), type: zod.string(), bytes: zod.number().optional(), ratio: zod.number().optional() }).optional(),
26
+ swap: zod.enum(['intent', 'idle', 'never']).optional(),
27
+ })
28
+
29
+ /** Zod's API plus `media()`, the record of an upload. */
30
+ // oxlint-disable-next-line no-redeclare -- a value and the namespace below that holds `z.infer`, one name as Zod's has
31
+ export const z = { ...zod, media: record } as typeof zod & { media: typeof record }
32
+
33
+ /** Zod's type helpers, under the name that has `media()`. */
34
+ export declare namespace z {
35
+ /** A schema's output type. */
36
+ export type infer<T> = zod.infer<T>
37
+ /** A schema's input type. */
38
+ export type input<T> = zod.input<T>
39
+ /** A schema's output type, under Zod's other name. */
40
+ export type output<T> = zod.output<T>
41
+ }
42
+
43
+ export type { MediaAsset }
@@ -0,0 +1,19 @@
1
+ // The app's migrations in order (research 28 §2, 34 §4): dev reads them per request, a build embeds them. `drizzle/` has
2
+ // a journal; Native's `migrations/` is numbered `.sql` files; Prisma's `prisma/migrations/` is a folder per migration.
3
+
4
+ import { existsSync, readFileSync, readdirSync } from 'node:fs'
5
+ import { join } from 'node:path'
6
+
7
+ const DIRS = ['drizzle', 'migrations', 'prisma/migrations']
8
+
9
+ export function readMigrations(root, dir = DIRS.find(d => existsSync(join(root, d)))) {
10
+ if (!dir) return []
11
+ const base = join(root, dir)
12
+ const journal = join(base, 'meta', '_journal.json')
13
+ if (existsSync(journal)) return JSON.parse(readFileSync(journal, 'utf8')).entries.map(e => ({ tag: e.tag, sql: readFileSync(join(base, `${e.tag}.sql`), 'utf8') }))
14
+ if (!existsSync(base)) return []
15
+ return readdirSync(base).sort().flatMap(name => {
16
+ const file = name.endsWith('.sql') ? join(base, name) : join(base, name, 'migration.sql')
17
+ return existsSync(file) ? [{ tag: name.replace(/\.sql$/, ''), sql: readFileSync(file, 'utf8') }] : []
18
+ })
19
+ }
@@ -0,0 +1,6 @@
1
+ /** What a tab compares, to learn a socket opened before it signed in isn't its own. Opaque, and only the same browser sees it. */
2
+ export async function sessionOf(ctx: unknown) {
3
+ const id = String((ctx as { user?: { id?: string | number } })?.user?.id ?? '')
4
+ const hash = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(`weft:${id}`))
5
+ return btoa(String.fromCharCode(...new Uint8Array(hash).slice(0, 12)))
6
+ }