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/README.md +112 -2
- package/dist/auth.d.ts +16 -0
- package/dist/auth.d.ts.map +1 -0
- package/dist/auth.js +75 -0
- package/dist/auth.js.map +1 -0
- package/dist/client.d.ts +149 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +478 -0
- package/dist/client.js.map +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -0
- package/dist/react.d.ts +12 -0
- package/dist/react.d.ts.map +1 -0
- package/dist/react.js +34 -0
- package/dist/react.js.map +1 -0
- package/dist/schema.d.ts +22 -0
- package/dist/schema.d.ts.map +1 -0
- package/dist/schema.js +57 -0
- package/dist/schema.js.map +1 -0
- package/dist/sql.d.ts +29 -0
- package/dist/sql.d.ts.map +1 -0
- package/dist/sql.js +203 -0
- package/dist/sql.js.map +1 -0
- package/dist/table.d.ts +20 -0
- package/dist/table.d.ts.map +1 -0
- package/dist/table.js +30 -0
- package/dist/table.js.map +1 -0
- package/dist/types.d.ts +82 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/values.d.ts +10 -0
- package/dist/values.d.ts.map +1 -0
- package/dist/values.js +109 -0
- package/dist/values.js.map +1 -0
- package/package.json +48 -4
- package/src/auth.ts +90 -0
- package/src/client.ts +551 -0
- package/src/index.ts +29 -0
- package/src/react.tsx +56 -0
- package/src/schema.ts +67 -0
- package/src/sql.ts +204 -0
- package/src/table.ts +48 -0
- package/src/types.ts +87 -0
- package/src/values.ts +99 -0
package/package.json
CHANGED
|
@@ -1,6 +1,50 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bisonbase",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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
|