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/src/index.ts ADDED
@@ -0,0 +1,29 @@
1
+ export {
2
+ ApiError,
3
+ BisonBase,
4
+ LiveQuery,
5
+ NotSignedInError,
6
+ SchemaError,
7
+ Table,
8
+ UserDb,
9
+ type AuthState,
10
+ type BisonBaseOptions,
11
+ type QueryResult,
12
+ type QueryState,
13
+ type InsertOf,
14
+ type RowOf,
15
+ } from './client.ts'
16
+ export type {
17
+ ColumnSpec,
18
+ ColumnType,
19
+ Columns,
20
+ InsertFor,
21
+ Json,
22
+ Ops,
23
+ OrderBy,
24
+ RowFor,
25
+ SelectOptions,
26
+ UpdateFor,
27
+ Where,
28
+ Wire,
29
+ } from './types.ts'
package/src/react.tsx ADDED
@@ -0,0 +1,56 @@
1
+ // React bindings. Importing this module makes `table.useSelect()` work.
2
+
3
+ import { useSyncExternalStore } from 'react'
4
+ import { BisonBase, setHooks, type AuthState, type LiveQuery, type QueryState } from './client.ts'
5
+
6
+ setHooks({
7
+ useQuery<R>(query: LiveQuery<R>): QueryState<R> {
8
+ return useSyncExternalStore(query.subscribe, query.getSnapshot, query.getSnapshot)
9
+ },
10
+ })
11
+
12
+ function instance(bb?: BisonBase): BisonBase {
13
+ const found = bb ?? BisonBase.instance
14
+ if (!found) throw new Error('Create a BisonBase before rendering components that use it')
15
+ return found
16
+ }
17
+
18
+ /** Whether the user is signed in; re-renders when that changes. */
19
+ export function useAuth(bb?: BisonBase): AuthState {
20
+ const b = instance(bb)
21
+ return useSyncExternalStore(
22
+ (listener) => b.onAuthChange(listener),
23
+ () => b.auth,
24
+ () => b.auth,
25
+ )
26
+ }
27
+
28
+ export interface AuthElementProps {
29
+ /** Default: the BisonBase created last. */
30
+ bb?: BisonBase
31
+ className?: string
32
+ }
33
+
34
+ /** A sign-in button, or a sign-out button once signed in. Unstyled; target `.bb-auth`. */
35
+ export function AuthElement({ bb, className }: AuthElementProps) {
36
+ const b = instance(bb)
37
+ const auth = useAuth(b)
38
+ const cls = className ? `bb-auth ${className}` : 'bb-auth'
39
+ if (auth.status === 'pending') return <span className={cls} data-status="pending">Signing in…</span>
40
+ if (auth.status === 'signed-in') {
41
+ return (
42
+ <span className={cls} data-status="signed-in">
43
+ <span className="bb-auth-label">Signed in</span>{' '}
44
+ <button type="button" onClick={() => void b.signOut()}>Sign out</button>
45
+ </span>
46
+ )
47
+ }
48
+ return (
49
+ <span className={cls} data-status="signed-out">
50
+ <button type="button" onClick={() => void b.signIn()}>Sign in</button>
51
+ {auth.error && <span className="bb-auth-error" role="alert"> {auth.error}</span>}
52
+ </span>
53
+ )
54
+ }
55
+
56
+ export { BisonBase }
package/src/schema.ts ADDED
@@ -0,0 +1,67 @@
1
+ // Bringing a per-user database in line with the tables the app declares: create missing
2
+ // tables, add new columns, and refuse anything that would change or drop existing data.
3
+
4
+ import { addColumnSql, createTableSql, SQL_TYPES } from './sql.ts'
5
+ import type { TableDef } from './table.ts'
6
+
7
+ /** What `GET ?op=schema` returns. */
8
+ export interface ServerSchema {
9
+ tables: {
10
+ name: string
11
+ type: string
12
+ columns: { name: string; type: string; not_null: boolean; default: unknown; pk: boolean }[]
13
+ }[]
14
+ }
15
+
16
+ /** The database's tables don't match the app's and can't be changed to match safely. */
17
+ export class SchemaError extends Error {
18
+ override name = 'SchemaError'
19
+ }
20
+
21
+ /** The statements that bring `server` in line with `tables`. Throws SchemaError if that's unsafe. */
22
+ export function migrationSql(tables: Iterable<TableDef>, server: ServerSchema, db: string): string[] {
23
+ const existing = new Map(server.tables.map((t) => [t.name.toLowerCase(), t]))
24
+ const statements: string[] = []
25
+ const fail = (table: string, msg: string) => {
26
+ throw new SchemaError(`Table "${table}" in ${db}: ${msg}`)
27
+ }
28
+ for (const table of tables) {
29
+ const found = existing.get(table.name.toLowerCase())
30
+ if (!found) {
31
+ statements.push(createTableSql(table))
32
+ continue
33
+ }
34
+ if (found.type !== 'table') fail(table.name, `the database has a ${found.type} with this name`)
35
+ const cols = new Map(found.columns.map((c) => [c.name.toLowerCase(), c]))
36
+ const id = cols.get('id')
37
+ if (!id || !id.pk || id.type.toUpperCase() !== 'INTEGER') {
38
+ fail(table.name, 'it exists but has no "id INTEGER PRIMARY KEY" column, so it wasn\'t created by this library')
39
+ }
40
+ if (found.columns.filter((c) => c.pk).length > 1) fail(table.name, 'it has a composite primary key')
41
+ for (const col of table.columns) {
42
+ const have = cols.get(col.name.toLowerCase())
43
+ const want = SQL_TYPES[col.type]
44
+ if (!have) {
45
+ if (!col.nullable && !col.default) {
46
+ fail(table.name, `new column "${col.name}" needs a default (or make it nullable) so existing rows get a value`)
47
+ }
48
+ statements.push(addColumnSql(table, col))
49
+ continue
50
+ }
51
+ if (have.type.toUpperCase() !== want) {
52
+ fail(table.name, `column "${col.name}" is ${have.type || 'untyped'} in the database but declared as ${col.type}; changing a column's type isn't supported`)
53
+ }
54
+ if (have.not_null === col.nullable) {
55
+ const [was, now] = have.not_null ? ['NOT NULL', 'nullable'] : ['nullable', 'not nullable']
56
+ fail(table.name, `column "${col.name}" is ${was} in the database but declared ${now}; changing that isn't supported`)
57
+ }
58
+ }
59
+ for (const have of found.columns) {
60
+ const name = have.name.toLowerCase()
61
+ if (name !== 'id' && !table.columns.some((c) => c.name.toLowerCase() === name)) {
62
+ fail(table.name, `column "${have.name}" is in the database but not declared; removing columns isn't supported because it would lose data. Declare it again`)
63
+ }
64
+ }
65
+ }
66
+ return statements
67
+ }
package/src/sql.ts ADDED
@@ -0,0 +1,204 @@
1
+ // Compiles structured operations (select / insert / update / delete) to SQL with quoted
2
+ // identifiers and bound parameters. Nothing outside this file and schema.ts writes SQL, so
3
+ // these operations can later move to a server-side relational API without changing callers.
4
+
5
+ import { encode } from './values.ts'
6
+ import type { ColumnDef, TableDef } from './table.ts'
7
+ import type { ColumnType, Wire } from './types.ts'
8
+
9
+ export interface Statement {
10
+ sql: string
11
+ params: Wire[]
12
+ }
13
+
14
+ export const quote = (name: string) => `"${name.replaceAll('"', '""')}"`
15
+
16
+ export const SQL_TYPES: Record<ColumnType, string> = {
17
+ text: 'TEXT',
18
+ integer: 'INTEGER',
19
+ real: 'REAL',
20
+ boolean: 'BOOLEAN',
21
+ blob: 'BLOB',
22
+ json: 'JSON',
23
+ }
24
+
25
+ const ID: ColumnDef = { name: 'id', type: 'integer', nullable: false }
26
+
27
+ function column(table: TableDef, name: string): ColumnDef {
28
+ if (name === 'id') return ID
29
+ const col = table.byName.get(name)
30
+ if (!col) throw new Error(`Table "${table.name}" has no column "${name}"`)
31
+ return col
32
+ }
33
+
34
+ /** A default value as an SQL literal (DEFAULT clauses can't take parameters). */
35
+ export function literal(col: ColumnDef, value: unknown): string {
36
+ const wire = encode(col.type, col.nullable, value, col.name)
37
+ if (wire === null) return 'NULL'
38
+ if (typeof wire === 'number') return String(wire)
39
+ if (typeof wire === 'string') return `'${wire.replaceAll("'", "''")}'`
40
+ const hex = Array.from(atob(wire.$base64), (c) => c.charCodeAt(0).toString(16).padStart(2, '0')).join('')
41
+ return `X'${hex}'`
42
+ }
43
+
44
+ /** A column definition for CREATE TABLE or ALTER TABLE ADD COLUMN. */
45
+ export function columnSql(col: ColumnDef): string {
46
+ let sql = `${quote(col.name)} ${SQL_TYPES[col.type]}`
47
+ if (!col.nullable) sql += ' NOT NULL'
48
+ if (col.default) sql += ` DEFAULT ${literal(col, col.default.value)}`
49
+ return sql
50
+ }
51
+
52
+ export function createTableSql(table: TableDef): string {
53
+ const cols = [`"id" INTEGER PRIMARY KEY`, ...table.columns.map(columnSql)]
54
+ return `CREATE TABLE IF NOT EXISTS ${quote(table.name)} (${cols.join(', ')})`
55
+ }
56
+
57
+ export function addColumnSql(table: TableDef, col: ColumnDef): string {
58
+ return `ALTER TABLE ${quote(table.name)} ADD COLUMN ${columnSql(col)}`
59
+ }
60
+
61
+ const allColumns = (table: TableDef) => ['id', ...table.columns.map((c) => c.name)].map(quote).join(', ')
62
+
63
+ const OPS: Record<string, string> = { $ne: '<>', $lt: '<', $lte: '<=', $gt: '>', $gte: '>=' }
64
+
65
+ function isOps(value: unknown): value is Record<string, unknown> {
66
+ if (typeof value !== 'object' || value === null || value instanceof Uint8Array || Array.isArray(value)) return false
67
+ const keys = Object.keys(value)
68
+ return keys.length > 0 && keys.every((k) => k.startsWith('$'))
69
+ }
70
+
71
+ /** A WHERE clause (including the keyword, or empty) and its parameters. */
72
+ export function whereSql(table: TableDef, where: object | number | undefined, params: Wire[]): string {
73
+ if (where === undefined) return ''
74
+ if (typeof where === 'number') where = { id: where }
75
+ const terms: string[] = []
76
+ for (const [name, cond] of Object.entries(where)) {
77
+ const col = column(table, name)
78
+ const q = quote(name)
79
+ if (cond === undefined) throw new Error(`where.${name} is undefined; leave it out or use null`)
80
+ if (!isOps(cond)) {
81
+ if (cond === null) terms.push(`${q} IS NULL`)
82
+ else {
83
+ terms.push(`${q} = ?`)
84
+ params.push(encode(col.type, col.nullable, cond, name))
85
+ }
86
+ continue
87
+ }
88
+ for (const [op, value] of Object.entries(cond)) {
89
+ if (value === undefined) continue
90
+ if (op === '$in') {
91
+ if (!Array.isArray(value)) throw new Error(`where.${name}.$in must be an array`)
92
+ if (value.length === 0) {
93
+ terms.push('0')
94
+ continue
95
+ }
96
+ terms.push(`${q} IN (${value.map(() => '?').join(', ')})`)
97
+ for (const v of value) params.push(encode(col.type, col.nullable, v, name))
98
+ } else if (op === '$ne' && value === null) {
99
+ terms.push(`${q} IS NOT NULL`)
100
+ } else if (op === '$ne') {
101
+ // Like JavaScript's !==, NULL counts as different from any value.
102
+ terms.push(`${q} IS NOT ?`)
103
+ params.push(encode(col.type, col.nullable, value, name))
104
+ } else if (OPS[op]) {
105
+ terms.push(`${q} ${OPS[op]} ?`)
106
+ params.push(encode(col.type, col.nullable, value, name))
107
+ } else {
108
+ throw new Error(`Unknown operator where.${name}.${op}`)
109
+ }
110
+ }
111
+ }
112
+ return terms.length ? ` WHERE ${terms.join(' AND ')}` : ''
113
+ }
114
+
115
+ function orderSql(table: TableDef, orderBy: string | readonly string[] | undefined): string {
116
+ if (orderBy === undefined) return ''
117
+ const items = typeof orderBy === 'string' ? [orderBy] : orderBy
118
+ if (items.length === 0) return ''
119
+ const parts = items.map((item) => {
120
+ const [name, dir = 'asc', ...rest] = item.trim().split(/\s+/)
121
+ const d = dir.toLowerCase()
122
+ if (rest.length || (d !== 'asc' && d !== 'desc')) throw new Error(`Bad orderBy "${item}"; use "column" or "column desc"`)
123
+ column(table, name)
124
+ return `${quote(name)} ${d.toUpperCase()}`
125
+ })
126
+ return ` ORDER BY ${parts.join(', ')}`
127
+ }
128
+
129
+ function count(name: string, n: number | undefined): n is number {
130
+ if (n === undefined) return false
131
+ if (!Number.isSafeInteger(n) || n < 0) throw new Error(`${name} must be a non-negative integer`)
132
+ return true
133
+ }
134
+
135
+ export interface SelectSpec {
136
+ where?: object | number
137
+ orderBy?: string | readonly string[]
138
+ limit?: number
139
+ offset?: number
140
+ }
141
+
142
+ export function selectSql(table: TableDef, opts: SelectSpec = {}): Statement {
143
+ const params: Wire[] = []
144
+ let sql = `SELECT ${allColumns(table)} FROM ${quote(table.name)}`
145
+ sql += whereSql(table, opts.where, params)
146
+ sql += orderSql(table, opts.orderBy)
147
+ const hasLimit = count('limit', opts.limit)
148
+ const hasOffset = count('offset', opts.offset)
149
+ if (hasLimit || hasOffset) {
150
+ sql += ' LIMIT ?'
151
+ params.push(hasLimit ? opts.limit! : -1)
152
+ }
153
+ if (hasOffset) {
154
+ sql += ' OFFSET ?'
155
+ params.push(opts.offset!)
156
+ }
157
+ return { sql, params }
158
+ }
159
+
160
+ /** INSERT one row, filling in defaults here so the code's defaults win over the database's. */
161
+ export function insertSql(table: TableDef, row: Record<string, unknown>): Statement {
162
+ for (const name of Object.keys(row)) column(table, name)
163
+ const names: string[] = []
164
+ const params: Wire[] = []
165
+ if (row.id !== undefined) {
166
+ names.push('id')
167
+ params.push(encode('integer', false, row.id, 'id'))
168
+ }
169
+ for (const col of table.columns) {
170
+ let value = row[col.name]
171
+ if (value === undefined) {
172
+ if (col.default) value = col.default.value
173
+ else if (col.nullable) value = null
174
+ else throw new Error(`Missing value for column "${col.name}" of table "${table.name}"`)
175
+ }
176
+ names.push(col.name)
177
+ params.push(encode(col.type, col.nullable, value, col.name))
178
+ }
179
+ const sql = names.length
180
+ ? `INSERT INTO ${quote(table.name)} (${names.map(quote).join(', ')}) VALUES (${names.map(() => '?').join(', ')})`
181
+ : `INSERT INTO ${quote(table.name)} DEFAULT VALUES`
182
+ return { sql: `${sql} RETURNING ${allColumns(table)}`, params }
183
+ }
184
+
185
+ /** UPDATE matching rows, or null when there's nothing to set. */
186
+ export function updateSql(table: TableDef, where: object | number, changes: Record<string, unknown>): Statement | null {
187
+ const params: Wire[] = []
188
+ const sets: string[] = []
189
+ for (const [name, value] of Object.entries(changes)) {
190
+ if (value === undefined) continue
191
+ if (name === 'id') throw new Error("A row's id can't be changed")
192
+ const col = column(table, name)
193
+ sets.push(`${quote(name)} = ?`)
194
+ params.push(encode(col.type, col.nullable, value, name))
195
+ }
196
+ if (sets.length === 0) return null
197
+ const sql = `UPDATE ${quote(table.name)} SET ${sets.join(', ')}${whereSql(table, where, params)}`
198
+ return { sql, params }
199
+ }
200
+
201
+ export function deleteSql(table: TableDef, where: object | number): Statement {
202
+ const params: Wire[] = []
203
+ return { sql: `DELETE FROM ${quote(table.name)}${whereSql(table, where, params)}`, params }
204
+ }
package/src/table.ts ADDED
@@ -0,0 +1,48 @@
1
+ // A table's columns, normalized from the spec passed to createTable.
2
+
3
+ import type { ColumnSpec, ColumnType, Columns } from './types.ts'
4
+
5
+ export interface ColumnDef {
6
+ name: string
7
+ type: ColumnType
8
+ nullable: boolean
9
+ /** Present when the column has a default. */
10
+ default?: { value: unknown }
11
+ }
12
+
13
+ export interface TableDef {
14
+ name: string
15
+ /** Every column except `id`, in declaration order. */
16
+ columns: ColumnDef[]
17
+ byName: Map<string, ColumnDef>
18
+ }
19
+
20
+ const TYPES: readonly ColumnType[] = ['text', 'integer', 'real', 'boolean', 'blob', 'json']
21
+ const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/
22
+
23
+ /** Check that a table or column name is a plain identifier. */
24
+ export function checkName(kind: string, name: string): void {
25
+ if (!IDENT.test(name)) {
26
+ throw new Error(`${kind} name "${name}" must be letters, digits and underscores, not starting with a digit`)
27
+ }
28
+ if (name.toLowerCase().startsWith('sqlite_')) throw new Error(`${kind} name "${name}" is reserved by SQLite`)
29
+ }
30
+
31
+ function parseColumn(name: string, spec: ColumnSpec): ColumnDef {
32
+ checkName('Column', name)
33
+ if (name.toLowerCase() === 'id') throw new Error('Every table gets an "id" column automatically; don\'t declare one')
34
+ const [base, hasDefault, value] = Array.isArray(spec) ? [spec[0], true, spec[1]] : [spec as string, false, undefined]
35
+ const nullable = base.endsWith('?')
36
+ const type = (nullable ? base.slice(0, -1) : base) as ColumnType
37
+ if (!TYPES.includes(type)) {
38
+ throw new Error(`Column "${name}" has unknown type "${base}"; use one of ${TYPES.join(', ')}`)
39
+ }
40
+ return { name, type, nullable, ...(hasDefault ? { default: { value } } : {}) }
41
+ }
42
+
43
+ export function parseTable(name: string, columns: Columns): TableDef {
44
+ checkName('Table', name)
45
+ const defs = Object.entries(columns).map(([col, spec]) => parseColumn(col, spec))
46
+ const byName = new Map(defs.map((c) => [c.name, c]))
47
+ return { name, columns: defs, byName }
48
+ }
package/src/types.ts ADDED
@@ -0,0 +1,87 @@
1
+ // Column specs and the row types inferred from them.
2
+
3
+ /** The column types a table can declare. */
4
+ export type ColumnType = 'text' | 'integer' | 'real' | 'boolean' | 'blob' | 'json'
5
+
6
+ /** Any value that survives JSON.stringify / JSON.parse. */
7
+ export type Json = string | number | boolean | null | readonly Json[] | { readonly [key: string]: Json }
8
+
9
+ interface TypeMap {
10
+ text: string
11
+ integer: number
12
+ real: number
13
+ boolean: boolean
14
+ blob: Uint8Array
15
+ json: Json
16
+ }
17
+
18
+ /** `"text"`, or `"text?"` for a nullable column. */
19
+ type BaseSpec = ColumnType | `${ColumnType}?`
20
+
21
+ type ValueOfBase<B> = B extends `${infer T extends ColumnType}?`
22
+ ? TypeMap[T] | null
23
+ : B extends ColumnType
24
+ ? TypeMap[B]
25
+ : never
26
+
27
+ /** `[type, default]`: the default must have the column's type. */
28
+ type WithDefault<B> = B extends BaseSpec ? readonly [B, ValueOfBase<B>] : never
29
+
30
+ /**
31
+ * A column: `"text"`, `"text?"` (nullable), or `["boolean", false]` (with a default).
32
+ * Nullable columns and columns with a default are optional on insert.
33
+ */
34
+ export type ColumnSpec = BaseSpec | WithDefault<BaseSpec>
35
+
36
+ /** The columns passed to `createTable`. `id` is added automatically. */
37
+ export type Columns = { readonly [name: string]: ColumnSpec }
38
+
39
+ type BaseOf<S> = S extends readonly [infer B, unknown] ? B : S
40
+
41
+ /** The TypeScript type of a column's values. */
42
+ export type ValueOf<S> = ValueOfBase<BaseOf<S>>
43
+
44
+ type Simplify<T> = { [K in keyof T]: T[K] } & {}
45
+
46
+ type OptionalKeys<C> = {
47
+ [K in keyof C]: C[K] extends readonly unknown[] ? K : C[K] extends `${string}?` ? K : never
48
+ }[keyof C]
49
+
50
+ /** A row as read from a table with these columns. */
51
+ export type RowFor<C> = Simplify<{ id: number } & { -readonly [K in keyof C]: ValueOf<C[K]> }>
52
+
53
+ /** What `insert` takes: nullable columns and columns with a default may be left out. */
54
+ export type InsertFor<C> = Simplify<
55
+ { id?: number } & { -readonly [K in Exclude<keyof C, OptionalKeys<C>>]: ValueOf<C[K]> } & {
56
+ -readonly [K in OptionalKeys<C>]?: ValueOf<C[K]>
57
+ }
58
+ >
59
+
60
+ /** What `update` takes: any subset of the columns. */
61
+ export type UpdateFor<C> = Simplify<{ -readonly [K in keyof C]?: ValueOf<C[K]> }>
62
+
63
+ /** Comparisons other than equality, e.g. `{ start: { $gte: from, $lt: to } }`. */
64
+ export interface Ops<T> {
65
+ $ne?: T
66
+ $lt?: T
67
+ $lte?: T
68
+ $gt?: T
69
+ $gte?: T
70
+ $in?: readonly T[]
71
+ }
72
+
73
+ /** Rows match when every listed column matches. `null` matches NULL. */
74
+ export type Where<R> = { [K in keyof R]?: R[K] | Ops<R[K]> }
75
+
76
+ /** A column name, optionally followed by `" asc"` or `" desc"`. */
77
+ export type OrderBy<R> = (keyof R & string) | `${keyof R & string} ${'asc' | 'desc'}`
78
+
79
+ export interface SelectOptions<R> {
80
+ where?: Where<R>
81
+ orderBy?: OrderBy<R> | readonly OrderBy<R>[]
82
+ limit?: number
83
+ offset?: number
84
+ }
85
+
86
+ /** A value as sent to or returned by the bisonbase API. */
87
+ export type Wire = string | number | null | { $base64: string }
package/src/values.ts ADDED
@@ -0,0 +1,99 @@
1
+ // Converting values between their TypeScript form and what the API sends over the wire:
2
+ // booleans are stored as 0/1, blobs travel as {"$base64": ...}, json is stored as text.
3
+
4
+ import type { ColumnType, Wire } from './types.ts'
5
+
6
+ export function toBase64(bytes: Uint8Array): string {
7
+ let s = ''
8
+ for (let i = 0; i < bytes.length; i += 0x8000) {
9
+ s += String.fromCharCode(...bytes.subarray(i, i + 0x8000))
10
+ }
11
+ return btoa(s)
12
+ }
13
+
14
+ export function fromBase64(b64: string): Uint8Array {
15
+ const s = atob(b64)
16
+ const bytes = new Uint8Array(s.length)
17
+ for (let i = 0; i < s.length; i++) bytes[i] = s.charCodeAt(i)
18
+ return bytes
19
+ }
20
+
21
+ /** Describe a value for an error message. */
22
+ function describe(value: unknown): string {
23
+ if (value === null) return 'null'
24
+ if (value instanceof Uint8Array) return 'Uint8Array'
25
+ if (Array.isArray(value)) return 'array'
26
+ return typeof value
27
+ }
28
+
29
+ const EXPECTED: Record<ColumnType, string> = {
30
+ text: 'a string',
31
+ integer: 'an integer',
32
+ real: 'a finite number',
33
+ boolean: 'a boolean',
34
+ blob: 'a Uint8Array',
35
+ json: 'a JSON value',
36
+ }
37
+
38
+ /** Convert a column value to its wire form, checking its type. `column` names it in errors. */
39
+ export function encode(type: ColumnType, nullable: boolean, value: unknown, column: string): Wire {
40
+ const bad = () =>
41
+ new TypeError(`Column "${column}" expects ${EXPECTED[type]}${nullable ? ' or null' : ''}, got ${describe(value)}`)
42
+ if (value === undefined) throw bad()
43
+ if (value === null) {
44
+ if (nullable) return null
45
+ if (type === 'json') return 'null'
46
+ throw bad()
47
+ }
48
+ switch (type) {
49
+ case 'text':
50
+ if (typeof value !== 'string') throw bad()
51
+ return value
52
+ case 'integer':
53
+ if (typeof value !== 'number' || !Number.isSafeInteger(value)) throw bad()
54
+ return value
55
+ case 'real':
56
+ if (typeof value !== 'number' || !Number.isFinite(value)) throw bad()
57
+ return value
58
+ case 'boolean':
59
+ if (typeof value !== 'boolean') throw bad()
60
+ return value ? 1 : 0
61
+ case 'blob':
62
+ if (!(value instanceof Uint8Array)) throw bad()
63
+ return { $base64: toBase64(value) }
64
+ case 'json': {
65
+ const text = JSON.stringify(value)
66
+ if (text === undefined) throw bad()
67
+ return text
68
+ }
69
+ }
70
+ }
71
+
72
+ /** Convert a wire value read from a column back to its TypeScript form. */
73
+ export function decode(type: ColumnType, value: Wire): unknown {
74
+ if (value === null) return null
75
+ switch (type) {
76
+ case 'boolean':
77
+ return value !== 0 && value !== '0' && value !== ''
78
+ case 'blob':
79
+ return typeof value === 'object' ? fromBase64(value.$base64) : new TextEncoder().encode(String(value))
80
+ case 'json':
81
+ // SQLite may have stored numeric JSON as a number (the column has numeric affinity).
82
+ return typeof value === 'string' ? JSON.parse(value) : value
83
+ default:
84
+ return value
85
+ }
86
+ }
87
+
88
+ /** Convert a value used in raw SQL (`db.sql`) to its wire form. */
89
+ export function encodeParam(value: unknown): Wire {
90
+ if (value === null || typeof value === 'string') return value
91
+ if (typeof value === 'number') {
92
+ if (!Number.isFinite(value)) throw new TypeError(`SQL parameter must be finite, got ${value}`)
93
+ return value
94
+ }
95
+ if (typeof value === 'boolean') return value ? 1 : 0
96
+ if (value instanceof Uint8Array) return { $base64: toBase64(value) }
97
+ if (value === undefined) throw new TypeError('SQL parameter is undefined')
98
+ return JSON.stringify(value)
99
+ }