@weft-js/db 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/LICENSE +16 -0
- package/README.md +30 -2
- package/package.json +84 -4
- package/plugins/dynamodb.d.mts +6 -0
- package/plugins/dynamodb.mjs +17 -0
- package/plugins/firestore.d.mts +6 -0
- package/plugins/firestore.mjs +17 -0
- package/plugins/kysely.d.mts +6 -0
- package/plugins/kysely.mjs +17 -0
- package/plugins/mongo.d.mts +6 -0
- package/plugins/mongo.mjs +17 -0
- package/plugins/native.d.mts +6 -0
- package/plugins/native.mjs +31 -0
- package/plugins/prisma-client.d.mts +6 -0
- package/plugins/prisma-client.mjs +45 -0
- package/plugins/prisma.d.mts +6 -0
- package/plugins/prisma.mjs +17 -0
- package/plugins/sql.d.mts +6 -0
- package/plugins/sql.mjs +17 -0
- package/src/driver.ts +20 -0
- package/src/dynamodb.ts +110 -0
- package/src/firestore.ts +99 -0
- package/src/kysely.ts +86 -0
- package/src/mongo.ts +182 -0
- package/src/native/auth.ts +45 -0
- package/src/native/bun-sqlite.d.ts +9 -0
- package/src/native/index.ts +71 -0
- package/src/native/mysql.ts +57 -0
- package/src/native/postgres.ts +26 -0
- package/src/native/sqlite.ts +74 -0
- package/src/prisma-client.ts +51 -0
- package/src/prisma.ts +62 -0
- package/src/sql.ts +118 -0
package/src/kysely.ts
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// A list source over Kysely. The types say nothing here: `table` and `columns` are strings, so Kysely's `dynamic`-free
|
|
2
|
+
// builder gets them from the spec, never from a request.
|
|
3
|
+
|
|
4
|
+
import type { ExpressionBuilder, Kysely, SelectQueryBuilder } from 'kysely'
|
|
5
|
+
import { likeOf, pageOf, plan, rowOf, storedId, written, type DbAdapter, type ListParams, type ListSource, type SourceSpec } from '@weft-js/start/list'
|
|
6
|
+
import { StartError } from '@weft-js/start/data'
|
|
7
|
+
import { driver } from './driver'
|
|
8
|
+
|
|
9
|
+
const { sql } = await driver('@weft-js/db/kysely', 'kysely', () => import('kysely'))
|
|
10
|
+
|
|
11
|
+
/** A source's allowlist, its table, and whether the engine can `returning`. */
|
|
12
|
+
export interface KyselySpec extends SourceSpec {
|
|
13
|
+
table: string
|
|
14
|
+
/** MySQL has no `returning`. Default true. */
|
|
15
|
+
returning?: boolean
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
type Tables = Record<string, Record<string, unknown>>
|
|
19
|
+
type Eb = ExpressionBuilder<Tables, string>
|
|
20
|
+
|
|
21
|
+
/** A `ListSource` over one table of any `Kysely<DB>`. */
|
|
22
|
+
export function kyselySource<Row = Record<string, unknown>, DB = unknown>(handle: Kysely<DB>, spec: KyselySpec): ListSource<Row> {
|
|
23
|
+
const db = handle as unknown as Kysely<Tables>
|
|
24
|
+
const { table, returning = true } = spec
|
|
25
|
+
const columns = Object.values(spec.columns)
|
|
26
|
+
const reading = rowOf<Row>(spec)
|
|
27
|
+
const idColumn = storedId(spec)
|
|
28
|
+
const byId = async (id: unknown) => {
|
|
29
|
+
const r = await db.selectFrom(table).select(columns).where(idColumn, '=', id).executeTakeFirst()
|
|
30
|
+
return r ? reading(r) : null
|
|
31
|
+
}
|
|
32
|
+
return {
|
|
33
|
+
async list(p: ListParams) {
|
|
34
|
+
const q = plan(spec, p)
|
|
35
|
+
const filtered = <O>(from: SelectQueryBuilder<Tables, string, O>) => {
|
|
36
|
+
let b = from
|
|
37
|
+
for (const w of q.where) {
|
|
38
|
+
const some = w.values.filter(v => v !== null)
|
|
39
|
+
b = b.where((eb: Eb) => eb.or([...(some.length ? [eb(w.column, 'in', some)] : []), ...(w.values.includes(null) ? [eb(w.column, 'is', null)] : [])]))
|
|
40
|
+
}
|
|
41
|
+
if (q.search) {
|
|
42
|
+
const like = likeOf(q.search.text)
|
|
43
|
+
b = b.where((eb: Eb) => eb.or(q.search!.columns.map(c => sql<boolean>`lower(${sql.ref(c)}) like ${like} escape '!'`)))
|
|
44
|
+
}
|
|
45
|
+
return b
|
|
46
|
+
}
|
|
47
|
+
let b = filtered(db.selectFrom(table).select(columns))
|
|
48
|
+
const dir = q.sort?.dir === 'desc' ? 'desc' : 'asc', cmp = dir === 'desc' ? '<' : '>'
|
|
49
|
+
if (p.after) {
|
|
50
|
+
const [value, id] = p.after
|
|
51
|
+
b = b.where((eb: Eb) => (q.sort ? eb.or([eb(q.sort.column, cmp, value), eb.and([eb(q.sort.column, '=', value), eb(idColumn, cmp, id)])]) : eb(idColumn, '>', id)))
|
|
52
|
+
}
|
|
53
|
+
b = q.sort ? b.orderBy(q.sort.column, dir).orderBy(idColumn, dir) : b.orderBy(idColumn, 'asc')
|
|
54
|
+
const cursor = p.mode === 'cursor'
|
|
55
|
+
const rows = (await b.limit(cursor ? p.limit + 1 : p.limit).offset(p.offset).execute()).map(reading)
|
|
56
|
+
const total = cursor ? undefined : Number((await filtered(db.selectFrom(table).select(sql<unknown>`count(*)`.as('n'))).executeTakeFirstOrThrow()).n)
|
|
57
|
+
return pageOf(rows, p, { sort: q.sort?.field, id: q.id.field }, total)
|
|
58
|
+
},
|
|
59
|
+
get: byId,
|
|
60
|
+
async create(data) {
|
|
61
|
+
const row = written(spec, data, true)
|
|
62
|
+
const id = row[idColumn]
|
|
63
|
+
if (returning) return reading(await db.insertInto(table).values(row).returning(columns).executeTakeFirstOrThrow())
|
|
64
|
+
if (id === undefined) throw new StartError('Q002', '`create` needs the id: MySQL can\'t return a row', 'generate it before the insert')
|
|
65
|
+
await db.insertInto(table).values(row).execute()
|
|
66
|
+
return (await byId(id))!
|
|
67
|
+
},
|
|
68
|
+
async update(id, patch) {
|
|
69
|
+
const row = written(spec, patch)
|
|
70
|
+
if (!Object.keys(row).length) return byId(id)
|
|
71
|
+
if (returning) {
|
|
72
|
+
const r = await db.updateTable(table).set(row).where(idColumn, '=', id).returning(columns).executeTakeFirst()
|
|
73
|
+
return r ? reading(r) : null
|
|
74
|
+
}
|
|
75
|
+
if (!(await byId(id))) return null
|
|
76
|
+
await db.updateTable(table).set(row).where(idColumn, '=', id).execute()
|
|
77
|
+
return byId(id)
|
|
78
|
+
},
|
|
79
|
+
async remove(id) {
|
|
80
|
+
return (await db.deleteFrom(table).where(idColumn, '=', id).executeTakeFirst()).numDeletedRows > 0n
|
|
81
|
+
},
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** `db.adapter@1` over a table of a Kysely instance. */
|
|
86
|
+
export const adapter: DbAdapter<Kysely<unknown>> = { name: 'kysely', source: (t, spec) => kyselySource(t, spec as KyselySpec) }
|
package/src/mongo.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// A list source over a MongoDB collection, and writes from anywhere as tag pokes. Every filter is `$eq`/`$in` on a
|
|
2
|
+
// checked scalar and a search is an escaped `$regex`, so a request has no way to name an operator. Takes
|
|
3
|
+
// whatever has the driver's collection methods, so `mongodb` is the app's dependency.
|
|
4
|
+
|
|
5
|
+
import { pageOf, plan, rowOf, storedId, written, type DbAdapter, type ListParams, type ListSource, type SourceSpec } from '@weft-js/start/list'
|
|
6
|
+
import { hub } from '@weft-js/start/hub'
|
|
7
|
+
import type { Source } from '@weft-js/start/changes'
|
|
8
|
+
|
|
9
|
+
/** What a change stream hands over, as far as anything here reads it. */
|
|
10
|
+
export interface MongoEvent extends MongoChange { ns?: { coll?: string }; fullDocument?: { tags?: string[] } }
|
|
11
|
+
|
|
12
|
+
/** The driver's change stream. */
|
|
13
|
+
export type MongoStream = AsyncIterable<MongoEvent> & { close?(): Promise<unknown> }
|
|
14
|
+
|
|
15
|
+
/** The methods of the driver's `Collection` a source calls. */
|
|
16
|
+
export interface MongoCollection {
|
|
17
|
+
collectionName?: string
|
|
18
|
+
find(filter: object, options?: object): { toArray(): Promise<object[]> }
|
|
19
|
+
countDocuments(filter: object): Promise<number>
|
|
20
|
+
findOne(filter: object, options?: object): Promise<object | null>
|
|
21
|
+
insertOne(doc: object): Promise<unknown>
|
|
22
|
+
findOneAndUpdate(filter: object, update: object, options: object): Promise<object | null>
|
|
23
|
+
deleteOne(filter: object): Promise<{ deletedCount?: number }>
|
|
24
|
+
watch(pipeline?: object[], options?: object): MongoStream
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const regex = (text: string) => ({ $regex: text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), $options: 'i' })
|
|
28
|
+
|
|
29
|
+
/** A `ListSource` over one collection. */
|
|
30
|
+
export function mongoSource<Row = Record<string, unknown>>(collection: MongoCollection, spec: SourceSpec): ListSource<Row> {
|
|
31
|
+
const stored = Object.values(spec.columns)
|
|
32
|
+
const idColumn = storedId(spec)
|
|
33
|
+
const projection = Object.fromEntries([...stored.map(c => [c, 1]), ...(stored.includes('_id') ? [] : [['_id', 0]])])
|
|
34
|
+
const reading = rowOf<Row>(spec)
|
|
35
|
+
return {
|
|
36
|
+
async list(p: ListParams) {
|
|
37
|
+
const q = plan(spec, p)
|
|
38
|
+
const and: object[] = q.where.map(w => ({ [w.column]: w.values.length === 1 ? { $eq: w.values[0] } : { $in: w.values } }))
|
|
39
|
+
if (q.search) and.push({ $or: q.search.columns.map(c => ({ [c]: regex(q.search!.text) })) })
|
|
40
|
+
const filter = and.length ? { $and: and } : {}
|
|
41
|
+
const dir = q.sort?.dir === 'desc' ? -1 : 1, cmp = dir < 0 ? '$lt' : '$gt'
|
|
42
|
+
const keyset = p.after && (q.sort
|
|
43
|
+
? { $or: [{ [q.sort.column]: { [cmp]: p.after[0] } }, { [q.sort.column]: { $eq: p.after[0] }, [idColumn]: { [cmp]: p.after[1] } }] }
|
|
44
|
+
: { [idColumn]: { $gt: p.after[1] } })
|
|
45
|
+
const sort = q.sort ? { [q.sort.column]: dir, [idColumn]: dir } : { [idColumn]: 1 }
|
|
46
|
+
const cursor = p.mode === 'cursor'
|
|
47
|
+
const rows = (await collection.find(keyset ? { $and: [...and, keyset] } : filter, { projection, sort, skip: p.offset, limit: cursor ? p.limit + 1 : p.limit }).toArray()).map(reading)
|
|
48
|
+
return pageOf(rows, p, { sort: q.sort?.field, id: q.id.field }, cursor ? undefined : await collection.countDocuments(filter))
|
|
49
|
+
},
|
|
50
|
+
get: async id => { const r = await collection.findOne({ [idColumn]: { $eq: id } }, { projection }); return r ? reading(r) : null },
|
|
51
|
+
async create(data) {
|
|
52
|
+
const doc = written(spec, data, true)
|
|
53
|
+
await collection.insertOne({ ...doc })
|
|
54
|
+
return reading(doc)
|
|
55
|
+
},
|
|
56
|
+
async update(id, patch) {
|
|
57
|
+
const r = await collection.findOneAndUpdate({ [idColumn]: { $eq: id } }, { $set: written(spec, patch) }, { projection, returnDocument: 'after' })
|
|
58
|
+
return r ? reading(r) : null
|
|
59
|
+
},
|
|
60
|
+
remove: async id => ((await collection.deleteOne({ [idColumn]: { $eq: id } })).deletedCount ?? 0) > 0,
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** `db.adapter@1` over a Mongo collection. */
|
|
65
|
+
export const adapter: DbAdapter<MongoCollection> = { name: 'mongo', source: (t, spec) => mongoSource(t, spec) }
|
|
66
|
+
|
|
67
|
+
/** The methods of the driver's `Db` the change feed calls. */
|
|
68
|
+
export interface MongoDb {
|
|
69
|
+
command(doc: object): Promise<{ setName?: string; msg?: string }>
|
|
70
|
+
watch(pipeline?: object[], options?: object): MongoStream
|
|
71
|
+
collection(name: string): { insertOne(doc: object): Promise<unknown> }
|
|
72
|
+
createCollection?(name: string, options?: object): Promise<unknown>
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const BARRIER = '__weft_barrier'
|
|
76
|
+
const said = (e: unknown) => (e as { message?: string } | null)?.message ?? String(e)
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* `realtime({ changes: mongoChanges(db, ['todos']) })`: one change stream, ids only, read by every instance. A mutation's
|
|
80
|
+
* barrier is a capped collection's insert, so the oplog puts it after the writes it covers. After a lost stream every
|
|
81
|
+
* collection is poked: the resume token is not kept, because a poke is cheaper than a stale page.
|
|
82
|
+
*/
|
|
83
|
+
export function mongoChanges(db: MongoDb, collections: string[], options: { retry?: number; external?: boolean } = {}): Source {
|
|
84
|
+
const retry = options.retry ?? 1000
|
|
85
|
+
return {
|
|
86
|
+
name: 'mongodb',
|
|
87
|
+
tables: collections,
|
|
88
|
+
hold: 120, // a stream hands over a batch at a time
|
|
89
|
+
external: options.external,
|
|
90
|
+
async probe() {
|
|
91
|
+
try {
|
|
92
|
+
const hello = await db.command({ hello: 1 })
|
|
93
|
+
if (hello.setName || hello.msg === 'isdbgrid') return { mode: 'feed', via: 'mongo-change-stream' }
|
|
94
|
+
return { mode: 'off', via: 'none', why: 'a standalone mongod has no oplog, so no change streams', fix: 'run it as a replica set: a single member is enough (`mongod --replSet rs0`, then `rs.initiate()`)' }
|
|
95
|
+
} catch (e) {
|
|
96
|
+
return { mode: 'off', via: 'none', why: `MongoDB didn't answer: ${said(e)}`, fix: 'check the connection string' }
|
|
97
|
+
}
|
|
98
|
+
},
|
|
99
|
+
async start(sink, how) {
|
|
100
|
+
if (how.mode === 'off') return () => {}
|
|
101
|
+
await db.createCollection?.(BARRIER, { capped: true, size: 1 << 20 }).catch(() => {})
|
|
102
|
+
let stream: MongoStream | undefined, stopped = false, timer: ReturnType<typeof setTimeout> | undefined, again = false
|
|
103
|
+
const pipeline = [{ $match: { 'ns.coll': { $in: [...collections, BARRIER] } } }, { $project: { operationType: 1, documentKey: 1, 'ns.coll': 1, 'fullDocument.tags': 1 } }]
|
|
104
|
+
const run = async () => {
|
|
105
|
+
if (stopped) return
|
|
106
|
+
try {
|
|
107
|
+
stream = db.watch(pipeline)
|
|
108
|
+
if (again) sink.lost()
|
|
109
|
+
sink.up()
|
|
110
|
+
again = true
|
|
111
|
+
for await (const c of stream) {
|
|
112
|
+
if (c.ns?.coll === BARRIER) { if (c.fullDocument?.tags) sink.settled(c.fullDocument.tags); continue }
|
|
113
|
+
if (!['insert', 'update', 'replace', 'delete'].includes(c.operationType) || !c.ns?.coll) continue
|
|
114
|
+
sink.changes([{ table: c.ns.coll, ...(c.documentKey?._id !== undefined && { id: String(c.documentKey._id) }) }])
|
|
115
|
+
}
|
|
116
|
+
} catch (e) {
|
|
117
|
+
if (!stopped) sink.down(`change stream: ${said(e)}`)
|
|
118
|
+
}
|
|
119
|
+
if (!stopped) timer = setTimeout(() => void run(), retry)
|
|
120
|
+
}
|
|
121
|
+
void run()
|
|
122
|
+
return async () => {
|
|
123
|
+
stopped = true
|
|
124
|
+
clearTimeout(timer)
|
|
125
|
+
await stream?.close?.()
|
|
126
|
+
}
|
|
127
|
+
},
|
|
128
|
+
settle: async tags => void (await db.collection(BARRIER).insertOne({ tags })),
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** The fields of a change-stream event that say what happened to which row. */
|
|
133
|
+
export interface MongoChange { operationType: string; documentKey?: { _id?: string | number | { toString(): string } } }
|
|
134
|
+
|
|
135
|
+
/** The tags of one change: the collection's, and the row's. Never the row itself. */
|
|
136
|
+
export function changeTags(change: MongoChange, tag: string): string[] {
|
|
137
|
+
if (!['insert', 'update', 'replace', 'delete'].includes(change.operationType)) return []
|
|
138
|
+
const id = change.documentKey?._id
|
|
139
|
+
return id === undefined ? [tag] : [tag, `${tag}:${String(id)}`]
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** How `watchMongo` turns a stream into pokes. */
|
|
143
|
+
export interface WatchOptions {
|
|
144
|
+
/** Default the collection's name. */
|
|
145
|
+
tag?: string
|
|
146
|
+
/** Where the tags go. Default the realtime hub, as `invalidate` would. */
|
|
147
|
+
poke?: (tags: string[]) => unknown
|
|
148
|
+
/** Ms between attempts after the stream fails. Default 1000. */
|
|
149
|
+
retry?: number
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Writes from cron, a shell or another service reach `.live` queries, as tags. Needs a replica set. After a lost stream
|
|
154
|
+
* the whole collection's tag is poked, because what happened meanwhile is unknown. Returns the way to stop.
|
|
155
|
+
*/
|
|
156
|
+
export function watchMongo(collection: MongoCollection, options: WatchOptions = {}): () => Promise<void> {
|
|
157
|
+
const tag = options.tag ?? collection.collectionName
|
|
158
|
+
if (!tag) throw new Error('`watchMongo` needs a `tag`')
|
|
159
|
+
const poke = options.poke ?? ((tags: string[]) => hub().poke(tags))
|
|
160
|
+
const retry = options.retry ?? 1000
|
|
161
|
+
let stream: MongoStream | undefined, stopped = false, timer: ReturnType<typeof setTimeout> | undefined
|
|
162
|
+
const run = async (again: boolean) => {
|
|
163
|
+
if (stopped) return
|
|
164
|
+
try {
|
|
165
|
+
stream = collection.watch([{ $project: { operationType: 1, documentKey: 1 } }])
|
|
166
|
+
if (again) await poke([tag])
|
|
167
|
+
for await (const change of stream) {
|
|
168
|
+
const tags = changeTags(change, tag)
|
|
169
|
+
if (tags.length) await poke(tags)
|
|
170
|
+
}
|
|
171
|
+
} catch {
|
|
172
|
+
// A closed stream ends here too; the loop below decides.
|
|
173
|
+
}
|
|
174
|
+
if (!stopped) timer = setTimeout(() => void run(true), retry)
|
|
175
|
+
}
|
|
176
|
+
void run(false)
|
|
177
|
+
return async () => {
|
|
178
|
+
stopped = true
|
|
179
|
+
clearTimeout(timer)
|
|
180
|
+
await stream?.close?.()
|
|
181
|
+
}
|
|
182
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// Better Auth on Native (research 34 §5): Better Auth speaks Kysely, so this is a Kysely dialect over Native's own
|
|
2
|
+
// connection. One connection on every engine and runtime, with no second driver and no ORM in the app's code.
|
|
3
|
+
|
|
4
|
+
import type { Dialect } from 'kysely'
|
|
5
|
+
import { driver } from '../driver'
|
|
6
|
+
import type { NativeDb } from './index'
|
|
7
|
+
|
|
8
|
+
const { MysqlAdapter, MysqlIntrospector, MysqlQueryCompiler, PostgresAdapter, PostgresIntrospector, PostgresQueryCompiler, SqliteAdapter, SqliteIntrospector, SqliteQueryCompiler } = await driver('@weft-js/db/plugin-native/auth', 'kysely', () => import('kysely'))
|
|
9
|
+
|
|
10
|
+
const PARTS = {
|
|
11
|
+
sqlite: { adapter: SqliteAdapter, compiler: SqliteQueryCompiler, introspector: SqliteIntrospector, type: 'sqlite' },
|
|
12
|
+
postgresql: { adapter: PostgresAdapter, compiler: PostgresQueryCompiler, introspector: PostgresIntrospector, type: 'postgres' },
|
|
13
|
+
mysql: { adapter: MysqlAdapter, compiler: MysqlQueryCompiler, introspector: MysqlIntrospector, type: 'mysql' },
|
|
14
|
+
} as const
|
|
15
|
+
|
|
16
|
+
/** Better Auth's `database` option on a Native client. It runs without transactions, which Better Auth treats as optional. */
|
|
17
|
+
export function nativeAuth(db: NativeDb) {
|
|
18
|
+
const { adapter, compiler, introspector, type } = PARTS[db.$weft.dialect]
|
|
19
|
+
const connection = {
|
|
20
|
+
async executeQuery({ sql, parameters }: { sql: string; parameters: readonly unknown[] }) {
|
|
21
|
+
const out = await db.query(sql, [...parameters]) as unknown
|
|
22
|
+
// mysql2 answers a write with a header, not rows.
|
|
23
|
+
if (Array.isArray(out)) return { rows: out }
|
|
24
|
+
return { rows: [], numAffectedRows: BigInt((out as { affectedRows?: number }).affectedRows ?? 0) }
|
|
25
|
+
},
|
|
26
|
+
streamQuery(): never {
|
|
27
|
+
throw new Error('Native has no streaming queries')
|
|
28
|
+
},
|
|
29
|
+
}
|
|
30
|
+
const dialect = {
|
|
31
|
+
createAdapter: () => new adapter(),
|
|
32
|
+
createDriver: () => ({
|
|
33
|
+
init: async () => {},
|
|
34
|
+
destroy: async () => {},
|
|
35
|
+
acquireConnection: () => Promise.resolve(connection),
|
|
36
|
+
releaseConnection: async () => {},
|
|
37
|
+
beginTransaction: async () => {},
|
|
38
|
+
commitTransaction: async () => {},
|
|
39
|
+
rollbackTransaction: async () => {},
|
|
40
|
+
}),
|
|
41
|
+
createIntrospector: (kysely: ConstructorParameters<typeof SqliteIntrospector>[0]) => new introspector(kysely),
|
|
42
|
+
createQueryCompiler: () => new compiler(),
|
|
43
|
+
} as unknown as Dialect
|
|
44
|
+
return { dialect, type, transaction: false }
|
|
45
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// Bun's built-in driver has no types on Node, where this package is checked: only what `open` calls.
|
|
2
|
+
declare module 'bun:sqlite' {
|
|
3
|
+
/** One open database file. */
|
|
4
|
+
export class Database {
|
|
5
|
+
constructor(path: string, options: { create: boolean })
|
|
6
|
+
prepare(text: string): { all(...params: unknown[]): Record<string, unknown>[] }
|
|
7
|
+
run(text: string): unknown
|
|
8
|
+
}
|
|
9
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
// The no-ORM client (research 34 §4): one `sql` tag over any driver. Values always go to the driver as parameters, so
|
|
2
|
+
// a template can't build SQL from a value. An engine file supplies the driver; `createNative` takes your own the same way.
|
|
3
|
+
|
|
4
|
+
import { gate, register, touch, type DbHandle, type Migration } from '@weft-js/start/db/core'
|
|
5
|
+
|
|
6
|
+
/** One row, by column name. */
|
|
7
|
+
export type Row = Record<string, unknown>
|
|
8
|
+
|
|
9
|
+
/** What an engine supplies: statements, files of them, a transaction and a lock. */
|
|
10
|
+
export interface Driver<D = unknown> {
|
|
11
|
+
dialect: DbHandle['dialect']
|
|
12
|
+
/** The client underneath, for anything `sql` doesn't cover. */
|
|
13
|
+
raw: D
|
|
14
|
+
/** Run one statement with bound values, and return its rows as objects. */
|
|
15
|
+
query(text: string, params: unknown[]): Promise<Row[]>
|
|
16
|
+
/** Run a whole file of statements. */
|
|
17
|
+
exec(text: string): Promise<void>
|
|
18
|
+
/** Run `fn` on one connection, committed when it resolves. */
|
|
19
|
+
transaction<T>(fn: (driver: Pick<Driver, 'query'>) => Promise<T>): Promise<T>
|
|
20
|
+
/** Hold off other instances while migrations run. */
|
|
21
|
+
locked<T>(fn: () => Promise<T>): Promise<T>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** The template tag: its values always go to the driver as parameters. */
|
|
25
|
+
export type Sql = (strings: TemplateStringsArray, ...values: unknown[]) => Promise<Row[]>
|
|
26
|
+
|
|
27
|
+
/** The client an app holds, as `ctx.db`. */
|
|
28
|
+
export interface NativeDb<D = unknown> {
|
|
29
|
+
sql: Sql
|
|
30
|
+
/** One statement you built, with its values bound. For a tool that writes SQL; in app code use `sql`. */
|
|
31
|
+
query: Driver['query']
|
|
32
|
+
driver: D
|
|
33
|
+
transaction<T>(fn: (tx: { sql: Sql }) => Promise<T>): Promise<T>
|
|
34
|
+
$weft: DbHandle
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const placeholder = (dialect: DbHandle['dialect'], i: number) => (dialect === 'postgresql' ? `$${i + 1}` : '?')
|
|
38
|
+
|
|
39
|
+
const tag = (handle: DbHandle, query: Driver['query']): Sql => (strings, ...values) => {
|
|
40
|
+
const text = strings.reduce((out, part, i) => out + placeholder(handle.dialect, i - 1) + part)
|
|
41
|
+
touch(handle, text)
|
|
42
|
+
return query(text, values)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** A client over any `Driver`, with its migrations behind the same gate as every statement. */
|
|
46
|
+
export function createNative<D>(driver: Driver<D>, { migrate = true }: { migrate?: boolean | undefined } = {}): NativeDb<D> {
|
|
47
|
+
const handle: DbHandle = {
|
|
48
|
+
dialect: driver.dialect,
|
|
49
|
+
exempt: new Set(),
|
|
50
|
+
migrate: (list: Migration[]) => driver.locked(async () => {
|
|
51
|
+
await driver.exec('create table if not exists __weft_migrations (tag varchar(255) primary key)')
|
|
52
|
+
const done = new Set((await driver.query('select tag from __weft_migrations', [])).map(r => r.tag))
|
|
53
|
+
for (const m of list) {
|
|
54
|
+
if (done.has(m.tag)) continue
|
|
55
|
+
// A file with only comments is a migration still being written; some engines refuse an empty query.
|
|
56
|
+
if (m.sql.replace(/--.*$/gm, '').trim()) await driver.exec(m.sql)
|
|
57
|
+
await driver.query(`insert into __weft_migrations (tag) values (${placeholder(driver.dialect, 0)})`, [m.tag])
|
|
58
|
+
}
|
|
59
|
+
}),
|
|
60
|
+
}
|
|
61
|
+
const ready = gate(handle, migrate)
|
|
62
|
+
const query: Driver['query'] = async (text, params) => (await ready(), driver.query(text, params))
|
|
63
|
+
const db = {
|
|
64
|
+
sql: tag(handle, query),
|
|
65
|
+
query,
|
|
66
|
+
driver: driver.raw,
|
|
67
|
+
transaction: async <T>(fn: (tx: { sql: Sql }) => Promise<T>) => (await ready(), driver.transaction(tx => fn({ sql: tag(handle, tx.query) }))),
|
|
68
|
+
}
|
|
69
|
+
return register(db, handle)
|
|
70
|
+
}
|
|
71
|
+
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// MySQL with no ORM: mysql2's pool, which connects on the first statement, so a build needs no database. Files run on
|
|
2
|
+
// one connection with multiple statements on; everything else has it off.
|
|
3
|
+
|
|
4
|
+
import { driver } from '../driver'
|
|
5
|
+
import { createNative, type Driver, type Row } from './index'
|
|
6
|
+
|
|
7
|
+
const { createConnection, createPool } = await driver('@weft-js/db/plugin-native/mysql', 'mysql2', () => import('mysql2/promise'))
|
|
8
|
+
|
|
9
|
+
const LOCK = 'weft_migrations'
|
|
10
|
+
type ExecuteValues = Parameters<Awaited<ReturnType<typeof createConnection>>['execute']>[1]
|
|
11
|
+
|
|
12
|
+
/** A client on a mysql2 pool. */
|
|
13
|
+
export function createDb({ url, migrate, max = 10 }: { url: string; migrate?: boolean; max?: number }) {
|
|
14
|
+
const uri = url || 'mysql://localhost/build'
|
|
15
|
+
// No `maxIdle` below the limit: that starts mysql2's reaper, a timer that keeps a finished `weft build` alive forever.
|
|
16
|
+
const pool = createPool({ uri, connectionLimit: max })
|
|
17
|
+
const rows = async (run: Promise<[unknown, unknown]>) => (await run)[0] as Row[]
|
|
18
|
+
const engine: Driver<typeof pool> = {
|
|
19
|
+
dialect: 'mysql',
|
|
20
|
+
raw: pool,
|
|
21
|
+
query: (text, params) => rows(pool.execute(text, params as ExecuteValues)),
|
|
22
|
+
async exec(text) {
|
|
23
|
+
const c = await createConnection({ uri, multipleStatements: true })
|
|
24
|
+
try {
|
|
25
|
+
await c.query(text)
|
|
26
|
+
} finally {
|
|
27
|
+
await c.end()
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
async transaction(fn) {
|
|
31
|
+
const c = await pool.getConnection()
|
|
32
|
+
try {
|
|
33
|
+
await c.beginTransaction()
|
|
34
|
+
const out = await fn({ query: (text, params) => rows(c.execute(text, params as ExecuteValues)) })
|
|
35
|
+
await c.commit()
|
|
36
|
+
return out
|
|
37
|
+
} catch (e) {
|
|
38
|
+
await c.rollback()
|
|
39
|
+
throw e
|
|
40
|
+
} finally {
|
|
41
|
+
c.release()
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
async locked(fn) {
|
|
45
|
+
const c = await pool.getConnection()
|
|
46
|
+
try {
|
|
47
|
+
const [got] = await c.query('select get_lock(?, 60) as got', [LOCK]) as unknown as [{ got: unknown }[]]
|
|
48
|
+
if (Number(got[0]?.got) !== 1) throw new Error('another instance held the migration lock for 60s')
|
|
49
|
+
return await fn()
|
|
50
|
+
} finally {
|
|
51
|
+
await c.query('select release_lock(?)', [LOCK]).catch(() => {})
|
|
52
|
+
c.release()
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
}
|
|
56
|
+
return createNative(engine, { migrate })
|
|
57
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// Postgres with no ORM: postgres.js, which connects on the first statement, so a build needs no database.
|
|
2
|
+
|
|
3
|
+
import { driver } from '../driver'
|
|
4
|
+
import { createNative, type Driver, type Row } from './index'
|
|
5
|
+
|
|
6
|
+
const { default: postgres } = await driver('@weft-js/db/plugin-native/postgres', 'postgres', () => import('postgres'))
|
|
7
|
+
|
|
8
|
+
const LOCK = 7_301_987
|
|
9
|
+
|
|
10
|
+
/** A client on a postgres.js connection pool. */
|
|
11
|
+
export function createDb({ url, migrate, max = 10 }: { url: string; migrate?: boolean; max?: number }) {
|
|
12
|
+
const client = postgres(url || 'postgres://localhost/build', { max, idle_timeout: 20, onnotice: () => {} })
|
|
13
|
+
const on = (c: { unsafe: typeof client.unsafe }) => (text: string, params: unknown[]) => c.unsafe(text, params as Parameters<typeof client.unsafe>[1]) as unknown as Promise<Row[]>
|
|
14
|
+
const engine: Driver<typeof client> = {
|
|
15
|
+
dialect: 'postgresql',
|
|
16
|
+
raw: client,
|
|
17
|
+
query: on(client),
|
|
18
|
+
exec: async text => void (await client.unsafe(text)),
|
|
19
|
+
transaction: fn => client.begin(tx => fn({ query: on(tx) })),
|
|
20
|
+
locked: fn => client.begin(async tx => {
|
|
21
|
+
await tx.unsafe(`select pg_advisory_xact_lock(${LOCK})`)
|
|
22
|
+
return fn()
|
|
23
|
+
}),
|
|
24
|
+
}
|
|
25
|
+
return createNative(engine, { migrate })
|
|
26
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
// SQLite with no ORM: Node's and Deno's `node:sqlite`, or Bun's `bun:sqlite`. One connection, opened on the first
|
|
2
|
+
// statement. A transaction's body is async, so it holds a gate: statements outside it wait.
|
|
3
|
+
|
|
4
|
+
import { AsyncLocalStorage } from 'node:async_hooks'
|
|
5
|
+
import { mkdirSync } from 'node:fs'
|
|
6
|
+
import { dirname } from 'node:path'
|
|
7
|
+
import type { SQLInputValue } from 'node:sqlite'
|
|
8
|
+
import { createNative, type Driver, type Row } from './index'
|
|
9
|
+
|
|
10
|
+
interface Conn {
|
|
11
|
+
all(text: string, params: unknown[]): Row[]
|
|
12
|
+
exec(text: string): void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
async function open(path: string): Promise<Conn> {
|
|
16
|
+
if (path !== ':memory:') mkdirSync(dirname(path), { recursive: true })
|
|
17
|
+
let conn: Conn
|
|
18
|
+
if ('Bun' in globalThis) {
|
|
19
|
+
const { Database } = await import('bun:sqlite')
|
|
20
|
+
const db = new Database(path, { create: true })
|
|
21
|
+
conn = { all: (text, params) => db.prepare(text).all(...params), exec: text => void db.run(text) }
|
|
22
|
+
} else {
|
|
23
|
+
const { DatabaseSync } = await import('node:sqlite')
|
|
24
|
+
const db = new DatabaseSync(path)
|
|
25
|
+
// `node:sqlite` on Deno refuses a boolean, which SQLite stores as 0 or 1 anyway.
|
|
26
|
+
conn = { all: (text, params) => db.prepare(text).all(...(params.map(v => (typeof v === 'boolean' ? Number(v) : v)) as SQLInputValue[])), exec: text => db.exec(text) }
|
|
27
|
+
}
|
|
28
|
+
if (path !== ':memory:') conn.exec('pragma journal_mode = wal')
|
|
29
|
+
conn.exec('pragma foreign_keys = on; pragma busy_timeout = 5000')
|
|
30
|
+
return conn
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** A client on one SQLite file, or `:memory:`. */
|
|
34
|
+
export function createDb({ url = 'file:.data/app.db', migrate }: { url?: string; migrate?: boolean } = {}) {
|
|
35
|
+
const path = url.replace(/^file:/, '')
|
|
36
|
+
let conn: Promise<Conn> | undefined
|
|
37
|
+
const connect = () => (conn ??= open(path))
|
|
38
|
+
const inside = new AsyncLocalStorage<boolean>()
|
|
39
|
+
let gate: Promise<unknown> = Promise.resolve()
|
|
40
|
+
const query = async (text: string, params: unknown[]) => {
|
|
41
|
+
const c = await connect()
|
|
42
|
+
if (!inside.getStore()) await gate
|
|
43
|
+
return c.all(text, params)
|
|
44
|
+
}
|
|
45
|
+
const driver: Driver<() => Promise<Conn>> = {
|
|
46
|
+
dialect: 'sqlite',
|
|
47
|
+
raw: connect,
|
|
48
|
+
query,
|
|
49
|
+
exec: async text => (await connect()).exec(text),
|
|
50
|
+
async transaction(fn) {
|
|
51
|
+
if (inside.getStore()) return fn({ query })
|
|
52
|
+
const c = await connect()
|
|
53
|
+
const before = gate
|
|
54
|
+
let release!: () => void
|
|
55
|
+
gate = new Promise<void>(ok => { release = ok })
|
|
56
|
+
await before
|
|
57
|
+
try {
|
|
58
|
+
c.exec('begin')
|
|
59
|
+
try {
|
|
60
|
+
const out = await inside.run(true, () => fn({ query }))
|
|
61
|
+
c.exec('commit')
|
|
62
|
+
return out
|
|
63
|
+
} catch (e) {
|
|
64
|
+
c.exec('rollback')
|
|
65
|
+
throw e
|
|
66
|
+
}
|
|
67
|
+
} finally {
|
|
68
|
+
release()
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
locked: fn => fn(),
|
|
72
|
+
}
|
|
73
|
+
return createNative(driver, { migrate })
|
|
74
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Prisma on weft's seam (research 34 §4): `withWeft(client, dialect)` returns the same client with the handle the server
|
|
2
|
+
// migrates and Q011 reads. Migrations are `prisma/migrations`, applied here because `prisma migrate deploy` isn't in a
|
|
3
|
+
// production bundle. Interactive transactions keep the lock and the statements on one connection. It stays Prisma's own
|
|
4
|
+
// client, so weft can't hold a statement: the server runs the gate per request instead, and a failed run doesn't fail it.
|
|
5
|
+
|
|
6
|
+
import { gate, register, touch, type DbHandle, type Migration } from '@weft-js/start/db/core'
|
|
7
|
+
import { runtime } from '@weft-js/start/data'
|
|
8
|
+
|
|
9
|
+
type Dialect = DbHandle['dialect']
|
|
10
|
+
interface Tx {
|
|
11
|
+
$executeRawUnsafe(sql: string, ...values: unknown[]): Promise<number>
|
|
12
|
+
$queryRawUnsafe<T>(sql: string, ...values: unknown[]): Promise<T>
|
|
13
|
+
}
|
|
14
|
+
interface Client extends Tx {
|
|
15
|
+
$on?(event: 'query', fn: (e: { query: string }) => void): void
|
|
16
|
+
$transaction<T>(fn: (tx: Tx) => Promise<T>, options: { maxWait: number; timeout: number }): Promise<T>
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const LOCK = 7_301_988
|
|
20
|
+
const statements = (sql: string) => sql.split(/;[ \t]*(?:\r?\n|$)/).map(s => s.replace(/^(\s*--.*\n)+/, '').trim()).filter(Boolean)
|
|
21
|
+
|
|
22
|
+
/** The same client, with `$weft`: the handle the server migrates and Q011 reads. */
|
|
23
|
+
export function withWeft<C extends object>(client: C, dialect: Dialect, { migrate = true }: { migrate?: boolean | undefined } = {}): C & { $weft: DbHandle } {
|
|
24
|
+
const c = client as unknown as Client
|
|
25
|
+
const handle: DbHandle = {
|
|
26
|
+
dialect,
|
|
27
|
+
exempt: new Set(),
|
|
28
|
+
migrate: (list: Migration[]) => c.$transaction(async tx => {
|
|
29
|
+
if (dialect === 'postgresql') await tx.$queryRawUnsafe(`select pg_advisory_xact_lock(${LOCK})::text`)
|
|
30
|
+
if (dialect === 'mysql') {
|
|
31
|
+
const got = await tx.$queryRawUnsafe<{ got: unknown }[]>(`select get_lock('weft_migrations', 60) as got`)
|
|
32
|
+
if (Number(got[0]?.got) !== 1) throw new Error('another instance held the migration lock for 60s')
|
|
33
|
+
}
|
|
34
|
+
try {
|
|
35
|
+
await tx.$executeRawUnsafe('create table if not exists __weft_migrations (tag varchar(255) primary key)')
|
|
36
|
+
const done = new Set((await tx.$queryRawUnsafe<{ tag: string }[]>('select tag from __weft_migrations')).map(r => r.tag))
|
|
37
|
+
for (const m of list) {
|
|
38
|
+
if (done.has(m.tag)) continue
|
|
39
|
+
for (const s of statements(m.sql)) await tx.$executeRawUnsafe(s)
|
|
40
|
+
await tx.$executeRawUnsafe(`insert into __weft_migrations (tag) values (${dialect === 'postgresql' ? '$1' : '?'})`, m.tag)
|
|
41
|
+
}
|
|
42
|
+
} finally {
|
|
43
|
+
// MySQL's lock belongs to the connection, not the transaction: a failed run would keep it in the pool.
|
|
44
|
+
if (dialect === 'mysql') await tx.$queryRawUnsafe(`select release_lock('weft_migrations')`).catch(() => {})
|
|
45
|
+
}
|
|
46
|
+
}, { maxWait: 60_000, timeout: 120_000 }),
|
|
47
|
+
}
|
|
48
|
+
c.$on?.('query', e => touch(handle, e.query))
|
|
49
|
+
;(runtime.dbs ??= new Set()).add(gate(handle, migrate))
|
|
50
|
+
return register(client, handle)
|
|
51
|
+
}
|