bisonbase 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.
package/package.json CHANGED
@@ -1,6 +1,50 @@
1
1
  {
2
2
  "name": "bisonbase",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "TypeScript and React client for bisonbase: sign-in, typed tables and live queries from the browser",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/jdlunger/bisonbase.git",
9
+ "directory": "client"
10
+ },
11
+ "type": "module",
12
+ "sideEffects": [
13
+ "./dist/react.js",
14
+ "./src/react.tsx"
15
+ ],
16
+ "exports": {
17
+ ".": {
18
+ "types": "./dist/index.d.ts",
19
+ "default": "./dist/index.js"
20
+ },
21
+ "./react": {
22
+ "types": "./dist/react.d.ts",
23
+ "default": "./dist/react.js"
24
+ }
25
+ },
26
+ "files": [
27
+ "dist",
28
+ "src"
29
+ ],
30
+ "scripts": {
31
+ "build": "tsc -p tsconfig.build.json",
32
+ "typecheck": "tsc -p tsconfig.json",
33
+ "test": "node --test 'test/*.test.ts'",
34
+ "prepublishOnly": "npm run build"
35
+ },
36
+ "peerDependencies": {
37
+ "react": ">=18"
38
+ },
39
+ "peerDependenciesMeta": {
40
+ "react": {
41
+ "optional": true
42
+ }
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^22.18.0",
46
+ "@types/react": "^19.0.0",
47
+ "react": "^19.0.0",
48
+ "typescript": "^5.9.0"
49
+ }
50
+ }
package/src/auth.ts ADDED
@@ -0,0 +1,90 @@
1
+ // "Sign in with bisonbase": the OAuth 2.1 authorization code flow with PKCE, in the browser.
2
+
3
+ const base64url = (bytes: Uint8Array) =>
4
+ btoa(String.fromCharCode(...bytes)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
5
+ const randomString = () => base64url(crypto.getRandomValues(new Uint8Array(32)))
6
+ const sha256 = async (s: string) =>
7
+ base64url(new Uint8Array(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(s))))
8
+
9
+ /** A sign-in in progress, kept in sessionStorage across the redirect. */
10
+ interface Flow {
11
+ state: string
12
+ verifier: string
13
+ server: string
14
+ back: string // where to return to in the app afterwards
15
+ }
16
+
17
+ export interface AuthConfig {
18
+ server: string
19
+ appId: string
20
+ appName: string
21
+ scope: string
22
+ }
23
+
24
+ const FLOW_KEY = 'bisonbase:sign-in'
25
+ // An app is identified by its origin, and bisonbase sends the user back to the current page.
26
+ const clientId = () => location.origin
27
+ const redirectUri = () => location.origin + location.pathname
28
+
29
+ /** Send the browser to bisonbase to sign in. */
30
+ export async function startSignIn(cfg: AuthConfig): Promise<void> {
31
+ const flow: Flow = {
32
+ state: randomString(),
33
+ verifier: randomString(),
34
+ server: cfg.server,
35
+ back: location.pathname + location.search + location.hash,
36
+ }
37
+ sessionStorage.setItem(FLOW_KEY, JSON.stringify(flow))
38
+ const params = new URLSearchParams({
39
+ response_type: 'code',
40
+ client_id: clientId(),
41
+ redirect_uri: redirectUri(),
42
+ scope: cfg.scope,
43
+ state: flow.state,
44
+ code_challenge: await sha256(flow.verifier),
45
+ code_challenge_method: 'S256',
46
+ app_id: cfg.appId,
47
+ app_name: cfg.appName,
48
+ })
49
+ location.assign(`${cfg.server}/oauth/authorize?${params}`)
50
+ }
51
+
52
+ /**
53
+ * If bisonbase just sent the browser back here, take the response out of the address bar
54
+ * (synchronously, before the app renders) and return a promise for the token.
55
+ */
56
+ export function takeSignInResponse(server: string): Promise<string> | null {
57
+ if (typeof location === 'undefined' || typeof sessionStorage === 'undefined') return null
58
+ const params = new URLSearchParams(location.search)
59
+ const saved = sessionStorage.getItem(FLOW_KEY)
60
+ if (!saved || !params.has('state')) return null
61
+ const flow: Flow = JSON.parse(saved)
62
+ if (flow.server !== server || params.get('state') !== flow.state) return null
63
+ sessionStorage.removeItem(FLOW_KEY)
64
+ history.replaceState(history.state, '', flow.back) // drop ?code=... from the address bar
65
+ return exchange(server, flow, params)
66
+ }
67
+
68
+ async function exchange(server: string, flow: Flow, params: URLSearchParams): Promise<string> {
69
+ if (params.get('iss') !== server) throw new Error("Sign-in failed: the response didn't come from the expected server.")
70
+ if (params.get('error') === 'access_denied') throw new Error('Sign-in was cancelled.')
71
+ if (params.has('error')) throw new Error(`Sign-in failed: ${params.get('error_description') ?? params.get('error')}`)
72
+ const res = await fetch(`${server}/oauth/token`, {
73
+ method: 'POST',
74
+ body: new URLSearchParams({
75
+ grant_type: 'authorization_code',
76
+ code: params.get('code') ?? '',
77
+ client_id: clientId(),
78
+ redirect_uri: redirectUri(),
79
+ code_verifier: flow.verifier,
80
+ }),
81
+ })
82
+ const json = await res.json().catch(() => ({}))
83
+ if (!res.ok) throw new Error(`Sign-in failed: ${json.error_description ?? json.error ?? res.statusText}`)
84
+ return json.access_token
85
+ }
86
+
87
+ /** Revoke a token on the server. Failures are ignored: the token is forgotten locally anyway. */
88
+ export async function revoke(server: string, token: string): Promise<void> {
89
+ await fetch(`${server}/oauth/revoke`, { method: 'POST', body: new URLSearchParams({ token }) }).catch(() => {})
90
+ }
package/src/client.ts ADDED
@@ -0,0 +1,551 @@
1
+ // The client: BisonBase (sign-in and the connection to the server), UserDb (one SQLite file
2
+ // per user) and Table (typed select / insert / update / delete, plus cached live queries).
3
+
4
+ import { revoke, startSignIn, takeSignInResponse } from './auth.ts'
5
+ import { migrationSql, SchemaError, type ServerSchema } from './schema.ts'
6
+ import { deleteSql, insertSql, selectSql, updateSql, type Statement } from './sql.ts'
7
+ import { checkName, parseTable, type TableDef } from './table.ts'
8
+ import type { Columns, InsertFor, RowFor, SelectOptions, UpdateFor, Where, Wire } from './types.ts'
9
+ import { decode, encodeParam } from './values.ts'
10
+
11
+ export interface BisonBaseOptions {
12
+ /** The bisonbase server. Default: `/bb` on the current origin. */
13
+ url?: string
14
+ /**
15
+ * Use this token instead of signing in through the browser, e.g. a personal token in a
16
+ * script or test. It isn't stored.
17
+ */
18
+ token?: string
19
+ }
20
+
21
+ export type AuthState =
22
+ /** `error` says why, when signing in failed or the token stopped working. */
23
+ | { status: 'signed-out'; error?: string }
24
+ /** Finishing a sign-in (trading the code from the redirect for a token). */
25
+ | { status: 'pending' }
26
+ | { status: 'signed-in' }
27
+
28
+ /** An error response from the bisonbase API. */
29
+ export class ApiError extends Error {
30
+ override name = 'ApiError'
31
+ readonly status: number
32
+ readonly code: string
33
+ constructor(status: number, code: string, message: string) {
34
+ super(message)
35
+ this.status = status
36
+ this.code = code
37
+ }
38
+ }
39
+
40
+ /** An operation needed the user to be signed in. */
41
+ export class NotSignedInError extends Error {
42
+ override name = 'NotSignedInError'
43
+ constructor() {
44
+ super('Not signed in')
45
+ }
46
+ }
47
+
48
+ export interface QueryResult {
49
+ columns: string[]
50
+ rows: Wire[][]
51
+ truncated: boolean
52
+ changes: number
53
+ last_insert_rowid: number
54
+ }
55
+
56
+ /** The state of a live query, as `useSelectState` returns it. */
57
+ export interface QueryState<R> {
58
+ /** Empty while signed out or before the first load. Kept (stale) while reloading or after an error. */
59
+ rows: readonly R[]
60
+ /** True until the first load finishes (only while signed in or signing in). */
61
+ loading: boolean
62
+ error: Error | null
63
+ }
64
+
65
+ /** Set by `bisonbase/react` when it's imported. */
66
+ interface Hooks {
67
+ useQuery<R>(query: LiveQuery<R>): QueryState<R>
68
+ }
69
+ let hooks: Hooks | null = null
70
+ /** @internal */
71
+ export function setHooks(h: Hooks): void {
72
+ hooks = h
73
+ }
74
+
75
+ const EMPTY: readonly never[] = Object.freeze([])
76
+ const SIGNED_OUT: QueryState<never> = Object.freeze({ rows: EMPTY, loading: false, error: null })
77
+ const LOADING: QueryState<never> = Object.freeze({ rows: EMPTY, loading: true, error: null })
78
+ const EXPIRED = 'Your sign-in expired or was revoked. Please sign in again.'
79
+
80
+ function storage(): Storage | null {
81
+ try {
82
+ return globalThis.localStorage ?? null
83
+ } catch {
84
+ return null
85
+ }
86
+ }
87
+
88
+ /** JSON with object keys sorted, so equal options give equal cache keys. */
89
+ function stableKey(value: unknown): string {
90
+ return JSON.stringify(value, (_, v) =>
91
+ v && typeof v === 'object' && !Array.isArray(v) && !(v instanceof Uint8Array)
92
+ ? Object.fromEntries(Object.entries(v).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
93
+ : v instanceof Uint8Array
94
+ ? { $bytes: Array.from(v) }
95
+ : v,
96
+ )
97
+ }
98
+
99
+ /** The connection to a bisonbase server for one app. Make one per page. */
100
+ export class BisonBase {
101
+ /** The instance `<AuthElement />` and other React helpers use. */
102
+ static instance: BisonBase | null = null
103
+
104
+ readonly appId: string
105
+ readonly appName: string
106
+ /** The server's origin, e.g. `https://example.com/bb`, without a trailing slash. */
107
+ readonly url: string
108
+
109
+ #token: string | null = null
110
+ #persist: boolean
111
+ #auth: AuthState = { status: 'signed-out' }
112
+ /** Resolves when no sign-in is pending. */
113
+ #settled: Promise<void> = Promise.resolve()
114
+ /** Bumped whenever the token changes, so results fetched with an old token are dropped. */
115
+ #epoch = 0
116
+ #authListeners = new Set<() => void>()
117
+ #dbs = new Map<string, UserDb>()
118
+ #queries = new Map<string, LiveQuery<any>>()
119
+
120
+ /**
121
+ * @param appId A UUID that identifies the app and never changes; the app's files live in
122
+ * `apps/<appId>/` in each user's account. Make one with `crypto.randomUUID()`.
123
+ * @param appName The name shown to users when they sign in and in their file browser.
124
+ */
125
+ constructor(appId: string, appName: string, options: BisonBaseOptions = {}) {
126
+ if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(appId)) {
127
+ throw new Error(`App id must be a UUID (make one with crypto.randomUUID()), got "${appId}"`)
128
+ }
129
+ this.appId = appId.toLowerCase()
130
+ this.appName = appName
131
+ const base = options.url ?? (typeof location !== 'undefined' ? `${location.origin}/bb` : undefined)
132
+ if (!base) throw new Error('Pass options.url: there is no current page to default to')
133
+ this.url = base.replace(/\/+$/, '')
134
+ this.#persist = options.token === undefined
135
+
136
+ if (BisonBase.instance) {
137
+ console.warn('BisonBase: more than one instance was created; React helpers use the latest one')
138
+ }
139
+ BisonBase.instance = this
140
+
141
+ if (options.token !== undefined) {
142
+ this.#setToken(options.token)
143
+ } else {
144
+ const response = takeSignInResponse(this.url)
145
+ if (response) {
146
+ this.#setAuth({ status: 'pending' })
147
+ this.#settled = response.then(
148
+ (token) => this.#setToken(token),
149
+ (e: Error) => this.#setToken(null, e.message),
150
+ )
151
+ } else {
152
+ const saved = storage()?.getItem(this.#tokenKey) ?? null
153
+ if (saved) this.#setToken(saved)
154
+ }
155
+ }
156
+
157
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
158
+ // Pick up changes made in other tabs or apps when the user comes back to this one.
159
+ window.addEventListener('focus', () => this.#refreshAll())
160
+ window.addEventListener('storage', (e) => {
161
+ if (e.key === this.#tokenKey && this.#persist && e.newValue !== this.#token) this.#setToken(e.newValue)
162
+ })
163
+ }
164
+ }
165
+
166
+ get #tokenKey() {
167
+ return `bisonbase:token:${this.url}:${this.appId}`
168
+ }
169
+
170
+ /** Whether the user is signed in. */
171
+ get auth(): AuthState {
172
+ return this.#auth
173
+ }
174
+
175
+ /** Call `listener` whenever `auth` changes. Returns a function that stops listening. */
176
+ onAuthChange(listener: () => void): () => void {
177
+ this.#authListeners.add(listener)
178
+ return () => this.#authListeners.delete(listener)
179
+ }
180
+
181
+ /** Send the browser to the server to sign in; it comes back to the current page. */
182
+ signIn(): Promise<void> {
183
+ return startSignIn({ server: this.url, appId: this.appId, appName: this.appName, scope: 'db:write' })
184
+ }
185
+
186
+ /** Forget the token here and revoke it on the server. */
187
+ async signOut(): Promise<void> {
188
+ const token = this.#token
189
+ this.#setToken(null)
190
+ if (token && this.#persist) await revoke(this.url, token)
191
+ }
192
+
193
+ /**
194
+ * A database that each user gets their own copy of, stored as `apps/<appId>/<name>.db`
195
+ * in their account. Only that user can read or change it.
196
+ */
197
+ createUserDb(name: string): UserDb {
198
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) throw new Error(`Database name "${name}" must be letters, digits, "_" and "-"`)
199
+ if (this.#dbs.has(name)) throw new Error(`Database "${name}" was already created`)
200
+ const db = new UserDb(this, name)
201
+ this.#dbs.set(name, db)
202
+ return db
203
+ }
204
+
205
+ #setAuth(state: AuthState) {
206
+ this.#auth = state
207
+ for (const listener of [...this.#authListeners]) listener()
208
+ }
209
+
210
+ #setToken(token: string | null, error?: string) {
211
+ const changed = token !== this.#token
212
+ this.#token = token
213
+ if (this.#persist) {
214
+ const s = storage()
215
+ if (token) s?.setItem(this.#tokenKey, token)
216
+ else s?.removeItem(this.#tokenKey)
217
+ }
218
+ this.#setAuth(token ? { status: 'signed-in' } : error ? { status: 'signed-out', error } : { status: 'signed-out' })
219
+ if (!changed) return
220
+ this.#epoch++
221
+ for (const db of this.#dbs.values()) db._reset()
222
+ for (const q of this.#queries.values()) q._authChanged()
223
+ }
224
+
225
+ #refreshAll() {
226
+ for (const q of this.#queries.values()) q._invalidate()
227
+ }
228
+
229
+ /** @internal A cached live query, shared by everything that asks for the same rows. */
230
+ _liveQuery<R>(key: string, run: () => Promise<R[]>, table: Table<any>): LiveQuery<R> {
231
+ let q = this.#queries.get(key)
232
+ if (!q) {
233
+ q = new LiveQuery(this, run, table, () => this.#queries.delete(key))
234
+ this.#queries.set(key, q)
235
+ }
236
+ return q
237
+ }
238
+
239
+ /** @internal Mark live queries on `table` (or on every table in `db`) stale, and reload the ones in use. */
240
+ _invalidate(db: UserDb, table?: Table<any>) {
241
+ for (const q of this.#queries.values()) {
242
+ if (table ? q.table === table : q.table.db === db) q._invalidate()
243
+ }
244
+ }
245
+
246
+ /** @internal */
247
+ get _epoch() {
248
+ return this.#epoch
249
+ }
250
+
251
+ /** @internal Whether a query can run now (signed in or signing in). */
252
+ get _active() {
253
+ return this.#auth.status !== 'signed-out'
254
+ }
255
+
256
+ /** @internal Call the files API, after any pending sign-in. */
257
+ async _request(path: string, op: string, body?: unknown): Promise<any> {
258
+ await this.#settled
259
+ const token = this.#token
260
+ if (!token) throw new NotSignedInError()
261
+ const headers: Record<string, string> = { authorization: `Bearer ${token}` }
262
+ if (body !== undefined) headers['content-type'] = 'application/json'
263
+ const res = await fetch(`${this.url}/api/v1/files/${path}?op=${op}`, {
264
+ method: op === 'schema' ? 'GET' : 'POST',
265
+ headers,
266
+ body: body === undefined ? undefined : JSON.stringify(body),
267
+ })
268
+ const json = await res.json().catch(() => ({}))
269
+ if (!res.ok) {
270
+ if (res.status === 401 && this.#token === token) this.#setToken(null, EXPIRED)
271
+ throw new ApiError(res.status, json.error?.code ?? 'unknown', json.error?.message ?? res.statusText)
272
+ }
273
+ return json
274
+ }
275
+ }
276
+
277
+ /** A query whose rows are kept up to date while something is subscribed to it. */
278
+ export class LiveQuery<R> {
279
+ #bb: BisonBase
280
+ #run: () => Promise<R[]>
281
+ #dispose: () => void
282
+ #state: QueryState<R>
283
+ #listeners = new Set<() => void>()
284
+ #stale = true
285
+ #running = false
286
+ #again = false
287
+ #gc: ReturnType<typeof setTimeout> | undefined
288
+ readonly table: Table<any>
289
+
290
+ constructor(bb: BisonBase, run: () => Promise<R[]>, table: Table<any>, dispose: () => void) {
291
+ this.#bb = bb
292
+ this.#run = run
293
+ this.table = table
294
+ this.#dispose = dispose
295
+ this.#state = bb._active ? LOADING : SIGNED_OUT
296
+ }
297
+
298
+ /** For `useSyncExternalStore`. */
299
+ subscribe = (listener: () => void): (() => void) => {
300
+ this.#listeners.add(listener)
301
+ clearTimeout(this.#gc)
302
+ if (this.#stale) void this.#load()
303
+ return () => {
304
+ this.#listeners.delete(listener)
305
+ // Keep the rows a little while in case the same query is used again soon.
306
+ if (this.#listeners.size === 0) {
307
+ this.#gc = setTimeout(this.#dispose, 30_000)
308
+ // In Node, don't keep the process alive just for this.
309
+ ;(this.#gc as { unref?: () => void }).unref?.()
310
+ }
311
+ }
312
+ }
313
+
314
+ /** For `useSyncExternalStore`. Returns the same object until something changes. */
315
+ getSnapshot = (): QueryState<R> => this.#state
316
+
317
+ #set(state: QueryState<R>) {
318
+ this.#state = state
319
+ for (const listener of [...this.#listeners]) listener()
320
+ }
321
+
322
+ /** @internal */
323
+ _invalidate() {
324
+ this.#stale = true
325
+ if (this.#listeners.size) void this.#load()
326
+ }
327
+
328
+ /** @internal */
329
+ _authChanged() {
330
+ this.#set(this.#bb._active ? LOADING : SIGNED_OUT)
331
+ this._invalidate()
332
+ }
333
+
334
+ async #load() {
335
+ if (!this.#bb._active) {
336
+ if (this.#state !== SIGNED_OUT) this.#set(SIGNED_OUT)
337
+ return
338
+ }
339
+ if (this.#running) {
340
+ this.#again = true
341
+ return
342
+ }
343
+ this.#running = true
344
+ try {
345
+ do {
346
+ this.#again = false
347
+ this.#stale = false
348
+ const epoch = this.#bb._epoch
349
+ try {
350
+ const rows = await this.#run()
351
+ if (!this.#again && epoch === this.#bb._epoch) this.#set({ rows, loading: false, error: null })
352
+ } catch (e) {
353
+ if (this.#again || epoch !== this.#bb._epoch) continue
354
+ if (e instanceof NotSignedInError) this.#set(SIGNED_OUT)
355
+ else this.#set({ rows: this.#state.rows, loading: false, error: e as Error })
356
+ }
357
+ } while (this.#again)
358
+ } finally {
359
+ this.#running = false
360
+ }
361
+ }
362
+ }
363
+
364
+ /** A database each signed-in user has their own copy of. Make one with `bb.createUserDb`. */
365
+ export class UserDb {
366
+ readonly bb: BisonBase
367
+ readonly name: string
368
+ /** Path within the user's files, e.g. `apps/<appId>/todos.db`. */
369
+ readonly path: string
370
+ #tables = new Map<string, Table<any>>()
371
+ #ready: Promise<void> | null = null
372
+
373
+ /** @internal Use `bb.createUserDb`. */
374
+ constructor(bb: BisonBase, name: string) {
375
+ this.bb = bb
376
+ this.name = name
377
+ this.path = `apps/${bb.appId}/${name}.db`
378
+ }
379
+
380
+ /**
381
+ * Declare a table. It's created, or new columns are added, the first time it's used after
382
+ * signing in. Every table also has an `id` integer primary key.
383
+ *
384
+ * ```ts
385
+ * const todos = db.createTable("todos", { content: "text", done: ["boolean", false] })
386
+ * ```
387
+ */
388
+ createTable<const C extends Columns>(name: string, columns: C): Table<C> {
389
+ checkName('Table', name)
390
+ if ([...this.#tables.keys()].some((t) => t.toLowerCase() === name.toLowerCase())) {
391
+ throw new Error(`Table "${name}" was already created in ${this.name}`)
392
+ }
393
+ const table = new Table<C>(this, parseTable(name, columns))
394
+ this.#tables.set(name, table)
395
+ this.#ready = null
396
+ return table
397
+ }
398
+
399
+ /**
400
+ * Run raw SQL against this database, returning rows as objects. Values interpolated into
401
+ * the template are sent as bound parameters. Booleans come back as 0/1 and JSON as text.
402
+ *
403
+ * ```ts
404
+ * const rows = await db.sql`SELECT count(*) AS n FROM todos WHERE done = ${false}`
405
+ * ```
406
+ */
407
+ async sql(strings: TemplateStringsArray, ...values: unknown[]): Promise<Record<string, Wire>[]> {
408
+ const result = await this._query({ sql: strings.join('?'), params: values.map(encodeParam) })
409
+ this.bb._invalidate(this)
410
+ return result.rows.map((row) => Object.fromEntries(result.columns.map((c, i) => [c, row[i]])))
411
+ }
412
+
413
+ /** @internal Forget that the schema was set up (the user changed). */
414
+ _reset() {
415
+ this.#ready = null
416
+ }
417
+
418
+ /** @internal Create the database and bring its tables up to date, once per sign-in. */
419
+ _ensure(): Promise<void> {
420
+ if (!this.#ready) {
421
+ const ready = this.#setUp()
422
+ this.#ready = ready
423
+ ready.catch(() => {
424
+ if (this.#ready === ready) this.#ready = null // try again next time
425
+ })
426
+ }
427
+ return this.#ready
428
+ }
429
+
430
+ async #setUp(attempt = 0): Promise<void> {
431
+ const tables = [...this.#tables.values()].map((t) => t.def)
432
+ await this.bb._request(this.path, 'mkdb')
433
+ const schema: ServerSchema = await this.bb._request(this.path, 'schema')
434
+ const statements = migrationSql(tables, schema, this.path)
435
+ if (statements.length === 0) return
436
+ try {
437
+ await this.bb._request(this.path, 'batch', { statements: statements.map((sql) => ({ sql })) })
438
+ } catch (e) {
439
+ // Another tab may have made the same change at the same time; look again.
440
+ if (attempt === 0 && e instanceof ApiError && e.status === 400) return this.#setUp(1)
441
+ throw e
442
+ }
443
+ }
444
+
445
+ /** @internal */
446
+ async _query(stmt: Statement): Promise<QueryResult> {
447
+ await this._ensure()
448
+ return this.bb._request(this.path, 'query', stmt)
449
+ }
450
+
451
+ /** @internal Run statements in one transaction. */
452
+ async _batch(stmts: Statement[]): Promise<QueryResult[]> {
453
+ await this._ensure()
454
+ return (await this.bb._request(this.path, 'batch', { statements: stmts })).results
455
+ }
456
+ }
457
+
458
+ /** A table in a database. Make one with `db.createTable`. */
459
+ export class Table<C extends Columns> {
460
+ readonly db: UserDb
461
+ /** @internal */
462
+ readonly def: TableDef
463
+
464
+ /** @internal Use `db.createTable`. */
465
+ constructor(db: UserDb, def: TableDef) {
466
+ this.db = db
467
+ this.def = def
468
+ }
469
+
470
+ get name(): string {
471
+ return this.def.name
472
+ }
473
+
474
+ #rows(result: QueryResult): RowFor<C>[] {
475
+ const types = result.columns.map((c) => (c === 'id' ? null : this.def.byName.get(c)?.type ?? null))
476
+ return result.rows.map(
477
+ (row) => Object.fromEntries(row.map((v, i) => [result.columns[i], types[i] ? decode(types[i], v) : v])) as RowFor<C>,
478
+ )
479
+ }
480
+
481
+ /** Fetch rows once. For rows that stay up to date in a component, use `useSelect`. */
482
+ async select(options?: SelectOptions<RowFor<C>>): Promise<RowFor<C>[]> {
483
+ const result = await this.db._query(selectSql(this.def, options))
484
+ if (result.truncated) console.warn(`BisonBase: results from ${this.name} were truncated by the server's row limit`)
485
+ return this.#rows(result)
486
+ }
487
+
488
+ /** Insert a row and return it as stored (with its `id` and defaults). */
489
+ insert(row: InsertFor<C>): Promise<RowFor<C>>
490
+ /** Insert several rows in one transaction. */
491
+ insert(rows: readonly InsertFor<C>[]): Promise<RowFor<C>[]>
492
+ async insert(rows: InsertFor<C> | readonly InsertFor<C>[]): Promise<RowFor<C> | RowFor<C>[]> {
493
+ const many = Array.isArray(rows)
494
+ const list = (many ? rows : [rows]) as Record<string, unknown>[]
495
+ const stmts = list.map((row) => insertSql(this.def, row))
496
+ if (stmts.length === 0) return []
497
+ const results = many ? await this.db._batch(stmts) : [await this.db._query(stmts[0])]
498
+ this.db.bb._invalidate(this.db, this)
499
+ const inserted = results.flatMap((r) => this.#rows(r))
500
+ return many ? inserted : inserted[0]
501
+ }
502
+
503
+ /**
504
+ * Change the rows matching `where` (or the row with that id). Returns how many changed.
505
+ * Pass `{}` to change every row.
506
+ */
507
+ async update(where: Where<RowFor<C>> | number, changes: UpdateFor<C>): Promise<number> {
508
+ const stmt = updateSql(this.def, where, changes as Record<string, unknown>)
509
+ if (!stmt) return 0
510
+ const result = await this.db._query(stmt)
511
+ this.db.bb._invalidate(this.db, this)
512
+ return result.changes
513
+ }
514
+
515
+ /** Delete the rows matching `where` (or the row with that id). Returns how many were deleted. Pass `{}` to delete every row. */
516
+ async delete(where: Where<RowFor<C>> | number): Promise<number> {
517
+ if (where === undefined) throw new Error('delete needs a where; pass {} to delete every row')
518
+ const result = await this.db._query(deleteSql(this.def, where))
519
+ this.db.bb._invalidate(this.db, this)
520
+ return result.changes
521
+ }
522
+
523
+ /** The live query for these options, shared with every other user of the same options. */
524
+ liveQuery(options?: SelectOptions<RowFor<C>>): LiveQuery<RowFor<C>> {
525
+ const key = stableKey([this.db.name, this.name, options ?? {}])
526
+ // Validate now so mistakes throw where the query is written, not later in a fetch.
527
+ selectSql(this.def, options)
528
+ return this.db.bb._liveQuery(key, () => this.select(options), this)
529
+ }
530
+
531
+ /**
532
+ * React hook: the rows matching `options`, kept up to date. Empty while signed out or
533
+ * loading. Needs `import "bisonbase/react"` somewhere in the app.
534
+ */
535
+ useSelect(options?: SelectOptions<RowFor<C>>): readonly RowFor<C>[] {
536
+ return this.useSelectState(options).rows
537
+ }
538
+
539
+ /** React hook: like `useSelect`, plus whether it's loading and the last error. */
540
+ useSelectState(options?: SelectOptions<RowFor<C>>): QueryState<RowFor<C>> {
541
+ if (!hooks) throw new Error('useSelect needs React: add `import "bisonbase/react"` to your app')
542
+ return hooks.useQuery(this.liveQuery(options))
543
+ }
544
+ }
545
+
546
+ export { SchemaError }
547
+
548
+ /** The row type of a table: `type Todo = RowOf<typeof todos>`. */
549
+ export type RowOf<T> = T extends Table<infer C> ? RowFor<C> : never
550
+ /** What a table's `insert` takes. */
551
+ export type InsertOf<T> = T extends Table<infer C> ? InsertFor<C> : never