@weft-js/db 0.0.0-stage → 0.1.1
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/prisma.ts
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
// A list source over a Prisma model delegate: `prismaSource(ctx.db.task, { columns })`. It only reads what a delegate
|
|
2
|
+
// has, so it needs nothing from `@prisma/client` and works with any generated client.
|
|
3
|
+
|
|
4
|
+
import { pageOf, plan, rowOf, storedId, written, type DbAdapter, type ListParams, type ListSource, type SourceSpec } from '@weft-js/start/list'
|
|
5
|
+
|
|
6
|
+
/** What a generated client's model has, whatever its schema. */
|
|
7
|
+
export interface PrismaDelegate {
|
|
8
|
+
findMany(args: unknown): Promise<object[]>
|
|
9
|
+
findUnique(args: unknown): Promise<object | null>
|
|
10
|
+
count(args: unknown): Promise<number>
|
|
11
|
+
create(args: unknown): Promise<object>
|
|
12
|
+
update(args: unknown): Promise<object>
|
|
13
|
+
delete(args: unknown): Promise<object>
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** A source's allowlist, and how `search` compares. */
|
|
17
|
+
export interface PrismaSpec extends SourceSpec {
|
|
18
|
+
/** `mode: 'insensitive'` on `contains`, which Postgres and Mongo have. SQLite and MySQL compare without case already. Default true. */
|
|
19
|
+
insensitive?: boolean
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const MISSING = 'P2025'
|
|
23
|
+
|
|
24
|
+
/** A `ListSource` over one Prisma model. */
|
|
25
|
+
export function prismaSource<Row = Record<string, unknown>>(model: PrismaDelegate, spec: PrismaSpec): ListSource<Row> {
|
|
26
|
+
const select = Object.fromEntries(Object.values(spec.columns).map(c => [c, true]))
|
|
27
|
+
const reading = rowOf<Row>(spec)
|
|
28
|
+
const idColumn = storedId(spec)
|
|
29
|
+
const missing = (e: unknown) => { if ((e as { code?: string } | null)?.code !== MISSING) throw e; return null }
|
|
30
|
+
// Prisma leaves `%` and `_` as wildcards in `contains`, so a term with either is checked again here.
|
|
31
|
+
const escaped = spec.insensitive !== false
|
|
32
|
+
return {
|
|
33
|
+
limits: { search: 'cased' },
|
|
34
|
+
async list(p: ListParams) {
|
|
35
|
+
const q = plan(spec, p)
|
|
36
|
+
const and: object[] = q.where.map(w => {
|
|
37
|
+
const some = w.values.filter(v => v !== null)
|
|
38
|
+
return { OR: [...(some.length ? [{ [w.column]: { in: some } }] : []), ...(w.values.includes(null) ? [{ [w.column]: null }] : [])] }
|
|
39
|
+
})
|
|
40
|
+
if (q.search) {
|
|
41
|
+
const term = q.search.text.replace(/[\\%_]/g, '\\$&')
|
|
42
|
+
and.push({ OR: q.search.columns.map(c => ({ [c]: { contains: term, ...(escaped ? { mode: 'insensitive' } : {}) } })) })
|
|
43
|
+
}
|
|
44
|
+
const where = { AND: and }
|
|
45
|
+
const dir = q.sort?.dir === 'desc' ? 'desc' : 'asc', cmp = dir === 'desc' ? 'lt' : 'gt'
|
|
46
|
+
const keyset = p.after
|
|
47
|
+
? q.sort ? { OR: [{ [q.sort.column]: { [cmp]: p.after[0] } }, { [q.sort.column]: p.after[0], [idColumn]: { [cmp]: p.after[1] } }] } : { [idColumn]: { gt: p.after[1] } }
|
|
48
|
+
: undefined
|
|
49
|
+
const orderBy = q.sort ? [{ [q.sort.column]: dir }, { [idColumn]: dir }] : [{ [idColumn]: 'asc' }]
|
|
50
|
+
const cursor = p.mode === 'cursor'
|
|
51
|
+
const rows = (await model.findMany({ select, where: keyset ? { AND: [where, keyset] } : where, orderBy, take: cursor ? p.limit + 1 : p.limit, skip: p.offset })).map(reading)
|
|
52
|
+
return pageOf(rows, p, { sort: q.sort?.field, id: q.id.field }, cursor ? undefined : await model.count({ where }))
|
|
53
|
+
},
|
|
54
|
+
get: async id => { const r = await model.findUnique({ where: { [idColumn]: id }, select }); return r ? reading(r) : null },
|
|
55
|
+
create: async data => reading(await model.create({ data: written(spec, data, true), select })),
|
|
56
|
+
update: (id, patch) => { const data = written(spec, patch); return model.update({ where: { [idColumn]: id }, data, select }).then(reading, missing) },
|
|
57
|
+
remove: id => model.delete({ where: { [idColumn]: id }, select: { [idColumn]: true } }).then(() => true, e => missing(e) ?? false),
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** `db.adapter@1` over a Prisma model. */
|
|
62
|
+
export const adapter: DbAdapter<PrismaDelegate> = { name: 'prisma', source: (t, spec) => prismaSource(t, spec) }
|
package/src/sql.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// A list source from bound SQL: `columns` is required, identifiers come only from it, and every value
|
|
2
|
+
// is a parameter. `target` is anything with `query(text, params)`: `ctx.db` from `@weft-js/db/plugin-native`, or a driver.
|
|
3
|
+
|
|
4
|
+
import { likeOf, pageOf, plan, rowOf, storedId, written, type DbAdapter, type ListParams, type ListSource, type SourceSpec } from '@weft-js/start/list'
|
|
5
|
+
import { StartError } from '@weft-js/start/data'
|
|
6
|
+
|
|
7
|
+
/** Whatever runs a bound statement, and says which dialect it speaks. */
|
|
8
|
+
export interface SqlTarget {
|
|
9
|
+
query(text: string, params: unknown[]): Promise<Record<string, unknown>[]>
|
|
10
|
+
dialect?: 'postgresql' | 'sqlite' | 'mysql'
|
|
11
|
+
$weft?: { dialect: 'postgresql' | 'sqlite' | 'mysql' }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** A source's allowlist, and the table it covers. */
|
|
15
|
+
export interface SqlSpec extends SourceSpec { table: string }
|
|
16
|
+
|
|
17
|
+
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
18
|
+
|
|
19
|
+
function ident(dialect: string) {
|
|
20
|
+
const q = dialect === 'mysql' ? '`' : '"'
|
|
21
|
+
return (name: string) => {
|
|
22
|
+
if (!IDENT.test(name)) throw new StartError('Q002', `\`${name}\` is not a plain identifier, so it can't go in SQL`, 'use a column name')
|
|
23
|
+
return `${q}${name}${q}`
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A `ListSource` over one table, as bound SQL. */
|
|
28
|
+
export function sqlSource<Row = Record<string, unknown>>(target: SqlTarget, spec: SqlSpec): ListSource<Row> {
|
|
29
|
+
const dialect = target.dialect ?? target.$weft?.dialect ?? 'postgresql'
|
|
30
|
+
const q = ident(dialect)
|
|
31
|
+
for (const c of [spec.table, ...Object.values(spec.columns)]) q(c)
|
|
32
|
+
const table = q(spec.table)
|
|
33
|
+
const select = Object.values(spec.columns).map(q).join(', ')
|
|
34
|
+
const reading = rowOf<Row>(spec)
|
|
35
|
+
const idColumn = q(storedId(spec))
|
|
36
|
+
const bind = (params: unknown[]) => (v: unknown) => {
|
|
37
|
+
params.push(v)
|
|
38
|
+
return dialect === 'postgresql' ? `$${params.length}` : '?'
|
|
39
|
+
}
|
|
40
|
+
const read = async (text: string, params: unknown[]) => (await target.query(text, params)).map(reading)
|
|
41
|
+
const byId = (id: unknown) => { const params: unknown[] = []; return read(`select ${select} from ${table} where ${idColumn} = ${bind(params)(id)}`, params).then(r => r[0] ?? null) }
|
|
42
|
+
return {
|
|
43
|
+
async list(p: ListParams) {
|
|
44
|
+
const qp = plan(spec, p)
|
|
45
|
+
const params: unknown[] = []
|
|
46
|
+
const put = bind(params)
|
|
47
|
+
const parts = qp.where.map(w => {
|
|
48
|
+
const some = w.values.filter(v => v !== null)
|
|
49
|
+
const hit = some.length === 1 ? `${q(w.column)} = ${put(some[0])}` : some.length ? `${q(w.column)} in (${some.map(put).join(', ')})` : ''
|
|
50
|
+
return w.values.includes(null) ? `(${hit ? `${hit} or ` : ''}${q(w.column)} is null)` : hit
|
|
51
|
+
})
|
|
52
|
+
if (qp.search) {
|
|
53
|
+
const text = likeOf(qp.search.text)
|
|
54
|
+
parts.push(`(${qp.search.columns.map(c => `lower(${q(c)}) like ${put(text)} escape '!'`).join(' or ')})`)
|
|
55
|
+
}
|
|
56
|
+
const filtered = [...parts], mark = params.length // what the count binds: the keyset isn't part of it
|
|
57
|
+
const cmp = qp.sort?.dir === 'desc' ? '<' : '>'
|
|
58
|
+
if (p.after) {
|
|
59
|
+
const [value, id] = p.after
|
|
60
|
+
const col = qp.sort && q(qp.sort.column)
|
|
61
|
+
parts.push(col ? `(${col} ${cmp} ${put(value)} or (${col} = ${put(value)} and ${idColumn} ${cmp} ${put(id)}))` : `${idColumn} > ${put(id)}`)
|
|
62
|
+
}
|
|
63
|
+
const where = (list: string[]) => (list.length ? ` where ${list.join(' and ')}` : '')
|
|
64
|
+
const dir = qp.sort?.dir === 'desc' ? 'desc' : 'asc'
|
|
65
|
+
const order = qp.sort ? `${q(qp.sort.column)} ${dir}, ${idColumn} ${dir}` : `${idColumn} asc`
|
|
66
|
+
const cursor = p.mode === 'cursor'
|
|
67
|
+
const counted = params.slice(0, mark)
|
|
68
|
+
const rows = await read(`select ${select} from ${table}${where(parts)} order by ${order} limit ${Math.trunc(cursor ? p.limit + 1 : p.limit)} offset ${Math.trunc(p.offset)}`, params)
|
|
69
|
+
const total = cursor ? undefined : Number((await target.query(`select count(*) as n from ${table}${where(filtered)}`, counted))[0]!.n)
|
|
70
|
+
return pageOf(rows, p, { sort: qp.sort?.field, id: qp.id.field }, total)
|
|
71
|
+
},
|
|
72
|
+
get: byId,
|
|
73
|
+
async create(data) {
|
|
74
|
+
const row = written(spec, data, true)
|
|
75
|
+
const id = data[spec.id ?? 'id']
|
|
76
|
+
if (id === undefined) throw new StartError('Q002', '`create` needs the id: a generated one can\'t be read back on every dialect', 'generate it before the insert')
|
|
77
|
+
const keys = Object.keys(row), params: unknown[] = [], put = bind(params)
|
|
78
|
+
await target.query(`insert into ${table} (${keys.map(q).join(', ')}) values (${keys.map(k => put(row[k])).join(', ')})`, params)
|
|
79
|
+
return (await byId(id))!
|
|
80
|
+
},
|
|
81
|
+
async update(id, patch) {
|
|
82
|
+
const row = written(spec, patch), params: unknown[] = [], put = bind(params)
|
|
83
|
+
const sets = Object.keys(row).map(k => `${q(k)} = ${put(row[k])}`)
|
|
84
|
+
if (!(await byId(id))) return null
|
|
85
|
+
if (sets.length) await target.query(`update ${table} set ${sets.join(', ')} where ${idColumn} = ${put(id)}`, params)
|
|
86
|
+
return byId(id)
|
|
87
|
+
},
|
|
88
|
+
async remove(id) {
|
|
89
|
+
if (!(await byId(id))) return false
|
|
90
|
+
const params: unknown[] = []
|
|
91
|
+
await target.query(`delete from ${table} where ${idColumn} = ${bind(params)(id)}`, params)
|
|
92
|
+
return true
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** `db.adapter@1` over bound SQL. */
|
|
98
|
+
export const adapter: DbAdapter<SqlTarget> = { name: 'sql', source: (t, spec) => sqlSource(t, spec as SqlSpec) }
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* A trigger so writes from anywhere (cron, psql, another service) reach `.live` queries, on the postgres bus: tags, never
|
|
102
|
+
* rows. Run the text once in a migration.
|
|
103
|
+
*/
|
|
104
|
+
export function postgresTrigger({ table, id = 'id', tag = table }: { table: string; id?: string; tag?: string }): string {
|
|
105
|
+
const q = ident('postgresql')
|
|
106
|
+
if (!/^[\w:-]+$/.test(tag)) throw new StartError('Q002', `\`${tag}\` can't be a tag in a trigger`)
|
|
107
|
+
const fn = `weft_poke_${table}`
|
|
108
|
+
return `create or replace function ${q(fn)}() returns trigger language plpgsql as $$
|
|
109
|
+
declare row_id text;
|
|
110
|
+
begin
|
|
111
|
+
if tg_op = 'DELETE' then row_id := old.${q(id)}::text; else row_id := new.${q(id)}::text; end if;
|
|
112
|
+
perform pg_notify('weft_bus', '=weft:poke' || chr(10) || json_build_object('t', json_build_array('${tag}', '${tag}:' || row_id))::text);
|
|
113
|
+
return null;
|
|
114
|
+
end $$;
|
|
115
|
+
drop trigger if exists ${q(fn)} on ${q(table)};
|
|
116
|
+
create trigger ${q(fn)} after insert or update or delete on ${q(table)} for each row execute function ${q(fn)}();
|
|
117
|
+
`
|
|
118
|
+
}
|