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/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
|
+
}
|