@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/link.ts ADDED
@@ -0,0 +1,456 @@
1
+ // One browser, one socket (research 45 §5). Every tab gets the same thing it always had, a duplex line of text frames,
2
+ // and only one tab, the leader, holds the WebSocket under them all. `channel.ts`, `live.ts` and `share.ts` ride it and
3
+ // know nothing about tabs: a line is a socket to them, and to the server, which gives each its own connection (mux.ts).
4
+ // That is why `id`, `exceptSelf`, `left`, `limits` and acks all mean what they did: per tab.
5
+ //
6
+ // campaign a Web Lock with a queue, so taking over needs no timer, and a ping for a leader that's frozen, not gone.
7
+ // tab death each tab holds a lock of its own and the leader queues on it: it comes through however the tab went.
8
+ // routing frames go up one BroadcastChannel only the leader reads, and down one per tab, so a frame wakes one tab.
9
+ // wire `f:<line>:<text>`, a prefix and not an envelope: the frame inside is never parsed here.
10
+ // session a socket keeps the cookies of its upgrade, and another tab may have signed in since. A tab that didn't dial
11
+ // asks the server who it is and compares; if they differ, the leader dials again.
12
+ //
13
+ // Without Web Locks every tab leads itself: its own socket, which is what every tab used to have.
14
+
15
+ /** Bump when a message here changes shape: tabs of two shapes share nothing, and each is its own client. */
16
+ const WIRE = 1
17
+ const NAME = `weft:link:${WIRE}`
18
+ const PING = 20_000, DEAD = 45_000, BASE = 200, CAP = 30_000, IDLE = 1000
19
+ export const BEAT = 5000
20
+ const PONG = 1500
21
+ /** The line went because the leader did: not the network's fault, so reopen at once. */
22
+ export const HANDOVER = 4900
23
+
24
+ // getRandomValues, not randomUUID: a page on plain http (a LAN address) has no randomUUID, and this runs on import.
25
+ export const tab = typeof crypto === 'undefined' ? '' : crypto.getRandomValues(new Uint32Array(1))[0]!.toString(36)
26
+ const locks = (): LockManager | undefined => (typeof navigator !== 'undefined' ? navigator.locks : undefined)
27
+ export const shareable = () => !!locks() && typeof BroadcastChannel !== 'undefined'
28
+
29
+ /** What a test reads. */
30
+ export const linkStats = { role: 'none' as 'none' | 'leader' | 'follower', steals: 0, dials: 0 }
31
+
32
+ // ---- a lock, a queue and a ping ---------------------------------------------------------------------------------------
33
+
34
+ export interface Campaign { probe(): void; stop(): void; leading(): boolean; pinged?: () => void }
35
+
36
+ /** `lead` when this tab takes the lock; `lose` when another tab takes it from under this one. */
37
+ export function campaign(name: string, won: () => void, lose: () => void, stolen?: () => void): Campaign {
38
+ const l = locks()
39
+ const lockName = `${name}:leader`
40
+ let on = true, leads = false
41
+ let release: (() => void) | undefined
42
+ let abort: AbortController | undefined
43
+ let waiting: ReturnType<typeof setTimeout> | undefined
44
+ let pongAt = 0
45
+ const hold = () => {
46
+ leads = true
47
+ clearTimeout(waiting) // a ping that went out before there was anyone to answer it
48
+ waiting = undefined
49
+ won()
50
+ }
51
+ if (!l || typeof BroadcastChannel === 'undefined') {
52
+ queueMicrotask(() => { if (on) hold() })
53
+ return { probe() {}, leading: () => leads, stop() { on = leads = false } }
54
+ }
55
+ const bc = new BroadcastChannel(`${name}:e`)
56
+ const me: Campaign = {
57
+ leading: () => leads,
58
+ probe() {
59
+ // Each hover asks, so a recent answer has to do: one message a second, not one a move.
60
+ if (leads || waiting !== undefined || !on || Date.now() - pongAt < 1000) return
61
+ bc.postMessage({ ping: tab })
62
+ const asked = Date.now()
63
+ waiting = setTimeout(() => {
64
+ waiting = undefined
65
+ // Late means this tab was the frozen one, and the answer is queued behind this timer: ask again.
66
+ if (Date.now() - asked > PONG * 1.5) return me.probe()
67
+ stolen?.()
68
+ const queued = abort
69
+ elect(true)
70
+ queued?.abort()
71
+ }, PONG)
72
+ },
73
+ stop() {
74
+ on = false
75
+ leads = false
76
+ clearTimeout(waiting)
77
+ clearInterval(timer)
78
+ abort?.abort()
79
+ release?.()
80
+ bc.close()
81
+ },
82
+ }
83
+ bc.onmessage = ({ data }) => {
84
+ if (data.ping && leads) {
85
+ bc.postMessage({ pong: data.ping })
86
+ me.pinged?.()
87
+ } else if (data.pong === tab) {
88
+ pongAt = Date.now()
89
+ clearTimeout(waiting)
90
+ waiting = undefined
91
+ }
92
+ }
93
+ function elect(steal = false) {
94
+ const ctl = new AbortController()
95
+ abort = ctl
96
+ l!.request(lockName, steal ? { steal: true } : { signal: ctl.signal }, () => new Promise<void>(ok => {
97
+ release = ok
98
+ hold()
99
+ })).catch(() => {
100
+ if (!on || abort !== ctl) return // this tab left the queue on purpose
101
+ if (leads) {
102
+ leads = false
103
+ lose()
104
+ }
105
+ elect()
106
+ })
107
+ }
108
+ const timer = setInterval(() => me.probe(), BEAT)
109
+ elect()
110
+ return me
111
+ }
112
+
113
+ // ---- this tab's own lock ----------------------------------------------------------------------------------------------
114
+
115
+ let tabHeld = false
116
+ let tabFree: (() => void) | undefined
117
+ const whenHeld: (() => void)[] = []
118
+
119
+ /** Held before the tab says anything, so a sibling that queues on it can't read a live tab as dead. */
120
+ export function holdTab(then: () => void) {
121
+ if (tabHeld || !locks()) return then()
122
+ whenHeld.push(then)
123
+ if (whenHeld.length > 1) return
124
+ locks()!.request(`${NAME}:tab:${tab}`, () => new Promise<void>(ok => {
125
+ tabFree = ok
126
+ tabHeld = true
127
+ for (const f of whenHeld.splice(0)) f()
128
+ })).catch(() => {})
129
+ }
130
+
131
+ /** Comes through when that tab is gone, crash included. */
132
+ export function freeTab() {
133
+ tabHeld = false
134
+ tabFree?.()
135
+ tabFree = undefined
136
+ }
137
+
138
+ export const whenGone = (t: string, fn: () => void) => void locks()?.request(`${NAME}:tab:${t}`, () => fn()).catch(() => {})
139
+
140
+ // ---- lines, as a tab sees them ----------------------------------------------------------------------------------------
141
+
142
+ export interface Line { send(text: string): void; close(): void }
143
+ interface Mine { id: string; hello: string; frame(text: string): void; closed(code: number, reason?: string): void }
144
+
145
+ const lines = new Map<string, Mine>()
146
+ let nth = 0
147
+
148
+ /** Sockets that never said hello in a row: the page's way to learn this host has none. */
149
+ export let socketFailures = 0
150
+ /** The one who asked goes round SSE once, and the socket gets another chance after. */
151
+ export const noSockets = () => {
152
+ const none = socketFailures >= 2
153
+ if (none) socketFailures = 0
154
+ return none
155
+ }
156
+ export const probeLink = () => election?.probe()
157
+
158
+ function end(l: Mine, code: number, reason?: string) {
159
+ lines.delete(l.id)
160
+ l.closed(code, reason)
161
+ }
162
+ const endAll = (code: number) => { for (const l of Array.from(lines.values())) end(l, code) }
163
+
164
+ function arrive(text: string) {
165
+ const at = text.indexOf(':', 2)
166
+ const l = lines.get(text.slice(2, at))
167
+ if (!l) return
168
+ if (text[0] === 'x') {
169
+ const split = text.indexOf(':', at + 1)
170
+ return end(l, +text.slice(at + 1, split), text.slice(split + 1))
171
+ }
172
+ l.frame(text.slice(at + 1))
173
+ }
174
+
175
+ /** `c`: a socket's frames (socket.ts). `r`: realtime events, opened with a `/_weft/rt` body (hub.ts). */
176
+ export function openLine(kind: 'c' | 'r', hello: string, frame: (text: string) => void, closed: (code: number, reason?: string) => void): Line {
177
+ start()
178
+ const id = `${tab}.${++nth}`
179
+ const l: Mine = { id, hello: `o:${id}:${kind}:${hello}`, frame, closed }
180
+ lines.set(id, l)
181
+ say(id, 'o', l.hello)
182
+ if (!election?.leading()) election?.probe()
183
+ return {
184
+ send(text) { if (lines.has(id)) say(id, 'f', `f:${id}:${text}`) },
185
+ close() {
186
+ if (!lines.has(id)) return
187
+ say(id, 'x', `x:${id}:`)
188
+ end(l, 1000)
189
+ },
190
+ }
191
+ }
192
+
193
+ // ---- up and down the tabs ---------------------------------------------------------------------------------------------
194
+
195
+ type Up = { tab: string; id: string; k: 'o' | 'x' | 'f'; l: string }
196
+ type Note =
197
+ | { t: 'lead' } | { t: 'lost'; code: number; never: boolean } | { t: 'who'; tab: string } | { t: 'you'; to: string; u?: string } | { t: 'redial' }
198
+ | { t: 'bye'; tab: string }
199
+
200
+ let running = false
201
+ let notes: BroadcastChannel | undefined
202
+ let down: BroadcastChannel | undefined
203
+ let election: Campaign | undefined
204
+ /** A tab that didn't dial holds its first lines until the session is checked. */
205
+ let gate: Up[] | undefined
206
+
207
+ function say(id: string, k: Up['k'], line: string) {
208
+ const m: Up = { tab, id, k, l: line }
209
+ if (gate) gate.push(m)
210
+ else if (election?.leading()) take(m)
211
+ else if (shareable()) holdTab(() => upTo.postMessage(m))
212
+ // else: this tab leads itself in a moment, and `lead` opens what's here
213
+ }
214
+ let upTo: BroadcastChannel
215
+
216
+ function start() {
217
+ if (running) return
218
+ running = true
219
+ if (shareable()) {
220
+ notes = new BroadcastChannel(NAME)
221
+ notes.onmessage = e => note(e.data)
222
+ down = new BroadcastChannel(`${NAME}:down:${tab}`)
223
+ down.onmessage = e => arrive(e.data)
224
+ upTo = new BroadcastChannel(`${NAME}:up`)
225
+ }
226
+ listen()
227
+ election = campaign(NAME, lead, () => hangUp(HANDOVER), () => void linkStats.steals++)
228
+ linkStats.role = 'follower'
229
+ if (!shareable() || session) return
230
+ gate = []
231
+ checkSession()
232
+ }
233
+
234
+ function note(m: Note) {
235
+ if (m.t === 'lead' && !election?.leading()) {
236
+ linkStats.role = 'follower'
237
+ endAll(HANDOVER)
238
+ } else if (m.t === 'lost') {
239
+ if (election?.leading()) return
240
+ if (m.never) socketFailures++
241
+ endAll(m.code)
242
+ }
243
+ else if (m.t === 'who') { if (election?.leading()) notes!.postMessage({ t: 'you', to: m.tab, u: open ? user : undefined }) }
244
+ else if (m.t === 'you' && m.to === tab) theirs?.(m.u)
245
+ else if (m.t === 'redial') { if (election?.leading() && ws) lost(ws, HANDOVER, true) }
246
+ else if (m.t === 'bye') dropTab(m.tab)
247
+ }
248
+
249
+ let session: Promise<void> | undefined
250
+ let theirs: ((u: string | undefined) => void) | undefined
251
+
252
+ /** One round trip, once, the first time a tab that didn't dial opens a line: a new socket used to cost the same. */
253
+ function checkSession() {
254
+ const mine = fetch('/_weft/rt', { method: 'POST', headers: { 'content-type': 'application/json', 'x-weft': '1' }, body: '{"w":1}' }).then(r => (r.ok ? r.json() : undefined)).then(r => r?.u as string | undefined).catch(() => undefined)
255
+ const leader = new Promise<string | undefined>(ok => {
256
+ theirs = ok
257
+ notes!.postMessage({ t: 'who', tab })
258
+ setTimeout(ok, 1000)
259
+ })
260
+ session = Promise.all([mine, leader]).then(([me, them]) => {
261
+ theirs = undefined
262
+ if (me !== undefined && them !== undefined && me !== them) notes!.postMessage({ t: 'redial' })
263
+ const held = gate
264
+ gate = undefined
265
+ for (const m of held ?? []) holdTab(() => (election?.leading() ? take(m) : upTo.postMessage(m)))
266
+ })
267
+ }
268
+
269
+ // ---- the leader -------------------------------------------------------------------------------------------------------
270
+
271
+ let ws: WebSocket | undefined
272
+ let open = false
273
+ let user: string | undefined
274
+ let attempt = 0
275
+ let redialed = 0
276
+ let retry: ReturnType<typeof setTimeout> | undefined
277
+ let ping: ReturnType<typeof setInterval> | undefined
278
+ let dead: ReturnType<typeof setTimeout> | undefined
279
+ let idle: ReturnType<typeof setTimeout> | undefined
280
+ const queue: string[] = []
281
+ /** Which lines each tab has open on this socket. */
282
+ const opened = new Map<string, Set<string>>()
283
+ const ears = new Map<string, BroadcastChannel>()
284
+ let upFrom: BroadcastChannel | undefined
285
+
286
+ const offline = () => ! navigator.onLine
287
+ const send = (line: string) => {
288
+ if (open) ws!.send(line)
289
+ else {
290
+ queue.push(line)
291
+ wantSocket()
292
+ }
293
+ }
294
+
295
+ function take(m: Up) {
296
+ let set = opened.get(m.tab)
297
+ if (m.k === 'o') {
298
+ if (!set) {
299
+ opened.set(m.tab, (set = new Set()))
300
+ if (m.tab !== tab) whenGone(m.tab, () => dropTab(m.tab))
301
+ }
302
+ set.add(m.id)
303
+ clearTimeout(idle)
304
+ idle = undefined
305
+ } else if (m.k === 'x') set?.delete(m.id)
306
+ // A line that closed before the socket came never has to be opened.
307
+ if (m.k === 'x' && !open) {
308
+ const hello = queue.findIndex(q => q.startsWith(`o:${m.id}:`))
309
+ if (hello >= 0) queue.splice(hello, 1)
310
+ } else send(m.l)
311
+ if (![...opened.values()].some(s => s.size)) idle ??= setTimeout(() => {
312
+ idle = undefined
313
+ if (ws && ![...opened.values()].some(s => s.size)) lost(ws, 1000)
314
+ }, IDLE)
315
+ }
316
+
317
+ function dropTab(t: string) {
318
+ const set = opened.get(t)
319
+ if (!set) return
320
+ opened.delete(t)
321
+ ears.get(t)?.close()
322
+ ears.delete(t)
323
+ for (const id of set) take({ tab: t, id, k: 'x', l: `x:${id}:` })
324
+ }
325
+
326
+ function arriveFrom(text: string) {
327
+ clearTimeout(dead)
328
+ dead = setTimeout(() => ws && lost(ws, 1006), DEAD)
329
+ if (text === 'p') return
330
+ if (text[0] === 'h') {
331
+ user = text.slice(2)
332
+ open = true
333
+ attempt = socketFailures = 0
334
+ for (const l of queue.splice(0)) ws!.send(l)
335
+ return
336
+ }
337
+ const owner = text.slice(2, text.indexOf('.', 2))
338
+ // The session this socket has ran out, and the cookies may have moved on: a new socket finds out, once.
339
+ const stale = text[0] === 'x' && text.includes(':4401:') && Date.now() - redialed > 30_000
340
+ if (stale) redialed = Date.now()
341
+ forward(owner, text)
342
+ if (stale && ws) lost(ws, HANDOVER, true)
343
+ }
344
+
345
+ function forward(owner: string, text: string) {
346
+ if (owner === tab) return arrive(text)
347
+ if (text[0] === 'x') opened.get(owner)?.delete(text.slice(2, text.indexOf(':', 2)))
348
+ let ear = ears.get(owner)
349
+ if (!ear) ears.set(owner, (ear = new BroadcastChannel(`${NAME}:down:${owner}`)))
350
+ ear.postMessage(text)
351
+ }
352
+
353
+ function wantSocket() {
354
+ if (ws || retry !== undefined || !election?.leading() || offline()) return
355
+ if (!attempt) return dial()
356
+ retry = setTimeout(() => {
357
+ retry = undefined
358
+ dial()
359
+ }, Math.random() * Math.min(CAP, BASE * 2 ** attempt))
360
+ }
361
+
362
+ function dial() {
363
+ if (ws || !election?.leading()) return
364
+ linkStats.dials++
365
+ const s = new WebSocket(`${location.protocol === 'https:' ? 'wss' : 'ws'}://${location.host}/_weft/ws?mux=1`)
366
+ ws = s
367
+ s.onmessage = e => arriveFrom(e.data)
368
+ s.onclose = () => lost(s, 1006)
369
+ ping = setInterval(() => { if (open) s.send('p') }, PING)
370
+ dead = setTimeout(() => lost(s, 1006), DEAD)
371
+ }
372
+
373
+ /** Not from the close event alone: offline, that can take as long as it likes, and WebKit's does. */
374
+ function lost(s: WebSocket, code: number, tell = true) {
375
+ if (ws !== s) return
376
+ const never = !open && code === 1006
377
+ ws = undefined
378
+ open = false
379
+ user = undefined
380
+ if (code !== HANDOVER) attempt++
381
+ if (never) socketFailures++
382
+ else if (code === 1006) socketFailures = 0
383
+ s.onmessage = s.onclose = null
384
+ s.close()
385
+ clearInterval(ping)
386
+ clearTimeout(dead)
387
+ queue.length = 0
388
+ opened.clear()
389
+ if (tell) notes?.postMessage({ t: 'lost', code, never })
390
+ endAll(code)
391
+ }
392
+
393
+ /** Offline, close first: a frame queued ahead of the close frame holds it back, and the server never hears. */
394
+ export function socketDown() {
395
+ if (ws) lost(ws, 1006)
396
+ }
397
+
398
+ function hangUp(code: number) {
399
+ clearTimeout(idle)
400
+ clearTimeout(retry)
401
+ idle = retry = undefined
402
+ // Not told: whoever leads next says so, and that ends every line in one go.
403
+ if (ws) lost(ws, code, false)
404
+ else endAll(code)
405
+ upFrom?.close()
406
+ upFrom = undefined
407
+ for (const ear of ears.values()) ear.close()
408
+ ears.clear()
409
+ attempt = 0
410
+ }
411
+
412
+ function lead() {
413
+ linkStats.role = 'leader'
414
+ if (shareable()) {
415
+ upFrom = new BroadcastChannel(`${NAME}:up`)
416
+ upFrom.onmessage = e => take(e.data)
417
+ notes!.postMessage({ t: 'lead' })
418
+ }
419
+ // What this tab held for the session check goes straight out, and its lines were waiting on nobody.
420
+ if (gate) {
421
+ const held = gate
422
+ gate = undefined
423
+ for (const m of held) if (m) take(m)
424
+ } else for (const l of lines.values()) take({ tab, id: l.id, k: 'o', l: l.hello })
425
+ }
426
+
427
+ // ---- the page ---------------------------------------------------------------------------------------------------------
428
+
429
+ function halt() {
430
+ if (!running) return
431
+ running = false
432
+ notes?.postMessage({ t: 'bye', tab })
433
+ election?.stop()
434
+ election = undefined
435
+ hangUp(1001)
436
+ for (const bc of [notes, down, upTo]) bc?.close()
437
+ notes = down = undefined
438
+ freeTab()
439
+ gate = undefined
440
+ session = undefined
441
+ linkStats.role = 'none'
442
+ }
443
+
444
+ let listening = false
445
+ function listen() {
446
+ if (listening) return
447
+ listening = true
448
+ addEventListener('pagehide', halt)
449
+ addEventListener('offline', socketDown)
450
+ addEventListener('online', () => {
451
+ clearTimeout(retry)
452
+ retry = undefined
453
+ attempt = 0
454
+ if (queue.length) wantSocket()
455
+ })
456
+ }
@@ -0,0 +1,50 @@
1
+ // The `db.adapter@1` default (research 13 §2.2): a list source over a Drizzle table. `columns` keeps its meaning here,
2
+ // so a table's other columns stay out of what's selected, returned or written.
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'
6
+
7
+ /** The Drizzle client and table a list source runs on. */
8
+ export interface DrizzleTarget { db: object; table: object }
9
+
10
+ /** 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 }
13
+
14
+ /** A `ListSource` over a Drizzle table. */
15
+ export function drizzleSource<Row = Record<string, unknown>>({ db: handle, table }: DrizzleTarget, spec: SourceSpec): ListSource<Row> {
16
+ const db = handle as Db
17
+ const col = (name: string) => (table as Record<string, Column>)[name]!
18
+ const selection = Object.fromEntries(Object.values(spec.columns).map(c => [c, col(c)]))
19
+ const public_ = rowOf<Row>(spec)
20
+ const id = col(storedId(spec))
21
+ const reading = (rows: object[]) => rows.map(public_)
22
+ const one = async (query: Chain) => reading(await query)[0] ?? null
23
+ return {
24
+ async list(p: ListParams): Promise<ListPage<Row>> {
25
+ 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
29
+ return w.values.includes(null) ? or(hit, isNull(col(w.column))) : hit
30
+ })
31
+ if (q.search) filters.push(or(...q.search.columns.map(c => sql`lower(${col(c)}) like ${likeOf(q.search!.text)} escape '!'`)))
32
+ const where = and(...filters)
33
+ const by = q.sort?.dir === 'desc' ? desc : asc
34
+ 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
36
+ const order = q.sort ? [by(col(q.sort.column)), by(id)] : [asc(id)]
37
+ 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
40
+ return pageOf(reading(rows), p, { sort: q.sort?.field, id: q.id.field }, total)
41
+ },
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,
46
+ }
47
+ }
48
+
49
+ /** `db.adapter@1` over a Drizzle table. */
50
+ export const adapter: DbAdapter<DrizzleTarget> = { name: 'drizzle', source: drizzleSource }