@weft-js/db 0.1.1 → 0.1.3
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 +558 -17
- package/package.json +13 -4
- package/plugins/dynamodb.mjs +10 -2
- package/plugins/firestore.mjs +10 -2
- package/plugins/kysely.mjs +10 -2
- package/plugins/mongo.mjs +10 -2
- package/plugins/native.mjs +16 -4
- package/plugins/prisma-client.mjs +29 -6
- package/plugins/prisma.mjs +10 -2
- package/plugins/sql.mjs +10 -2
- package/src/driver.ts +11 -4
- package/src/dynamodb.ts +165 -49
- package/src/firestore.ts +89 -31
- package/src/kysely.ts +108 -32
- package/src/mongo.ts +125 -30
- package/src/native/auth.ts +37 -9
- package/src/native/index.ts +36 -23
- package/src/native/mysql.ts +22 -5
- package/src/native/postgres.ts +28 -10
- package/src/native/sqlite.ts +20 -6
- package/src/prisma-client.ts +53 -24
- package/src/prisma.ts +102 -28
- package/src/sql.ts +141 -43
package/src/native/postgres.ts
CHANGED
|
@@ -3,24 +3,42 @@
|
|
|
3
3
|
import { driver } from '../driver'
|
|
4
4
|
import { createNative, type Driver, type Row } from './index'
|
|
5
5
|
|
|
6
|
-
const { default: postgres } = await driver(
|
|
6
|
+
const { default: postgres } = await driver(
|
|
7
|
+
'@weft-js/db/plugin-native/postgres',
|
|
8
|
+
'postgres',
|
|
9
|
+
() => import('postgres'),
|
|
10
|
+
)
|
|
7
11
|
|
|
8
12
|
const LOCK = 7_301_987
|
|
9
13
|
|
|
10
14
|
/** A client on a postgres.js connection pool. */
|
|
11
|
-
export function createDb({
|
|
12
|
-
|
|
13
|
-
|
|
15
|
+
export function createDb({
|
|
16
|
+
url,
|
|
17
|
+
migrate,
|
|
18
|
+
max = 10,
|
|
19
|
+
}: {
|
|
20
|
+
url: string
|
|
21
|
+
migrate?: boolean
|
|
22
|
+
max?: number
|
|
23
|
+
}) {
|
|
24
|
+
const client = postgres(url || 'postgres://localhost/build', {
|
|
25
|
+
max,
|
|
26
|
+
idle_timeout: 20,
|
|
27
|
+
onnotice: () => {},
|
|
28
|
+
})
|
|
29
|
+
const on = (c: { unsafe: typeof client.unsafe }) => (text: string, params: unknown[]) =>
|
|
30
|
+
c.unsafe(text, params as Parameters<typeof client.unsafe>[1]) as unknown as Promise<Row[]>
|
|
14
31
|
const engine: Driver<typeof client> = {
|
|
15
32
|
dialect: 'postgresql',
|
|
16
33
|
raw: client,
|
|
17
34
|
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 =>
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
35
|
+
exec: async (text) => void (await client.unsafe(text)),
|
|
36
|
+
transaction: (fn) => client.begin((tx) => fn({ query: on(tx) })),
|
|
37
|
+
locked: (fn) =>
|
|
38
|
+
client.begin(async (tx) => {
|
|
39
|
+
await tx.unsafe(`select pg_advisory_xact_lock(${LOCK})`)
|
|
40
|
+
return fn()
|
|
41
|
+
}),
|
|
24
42
|
}
|
|
25
43
|
return createNative(engine, { migrate })
|
|
26
44
|
}
|
package/src/native/sqlite.ts
CHANGED
|
@@ -18,12 +18,21 @@ async function open(path: string): Promise<Conn> {
|
|
|
18
18
|
if ('Bun' in globalThis) {
|
|
19
19
|
const { Database } = await import('bun:sqlite')
|
|
20
20
|
const db = new Database(path, { create: true })
|
|
21
|
-
conn = {
|
|
21
|
+
conn = {
|
|
22
|
+
all: (text, params) => db.prepare(text).all(...params),
|
|
23
|
+
exec: (text) => void db.run(text),
|
|
24
|
+
}
|
|
22
25
|
} else {
|
|
23
26
|
const { DatabaseSync } = await import('node:sqlite')
|
|
24
27
|
const db = new DatabaseSync(path)
|
|
25
28
|
// `node:sqlite` on Deno refuses a boolean, which SQLite stores as 0 or 1 anyway.
|
|
26
|
-
conn = {
|
|
29
|
+
conn = {
|
|
30
|
+
all: (text, params) =>
|
|
31
|
+
db
|
|
32
|
+
.prepare(text)
|
|
33
|
+
.all(...(params.map((v) => (typeof v === 'boolean' ? Number(v) : v)) as SQLInputValue[])),
|
|
34
|
+
exec: (text) => db.exec(text),
|
|
35
|
+
}
|
|
27
36
|
}
|
|
28
37
|
if (path !== ':memory:') conn.exec('pragma journal_mode = wal')
|
|
29
38
|
conn.exec('pragma foreign_keys = on; pragma busy_timeout = 5000')
|
|
@@ -31,7 +40,10 @@ async function open(path: string): Promise<Conn> {
|
|
|
31
40
|
}
|
|
32
41
|
|
|
33
42
|
/** A client on one SQLite file, or `:memory:`. */
|
|
34
|
-
export function createDb({
|
|
43
|
+
export function createDb({
|
|
44
|
+
url = 'file:.data/app.db',
|
|
45
|
+
migrate,
|
|
46
|
+
}: { url?: string; migrate?: boolean } = {}) {
|
|
35
47
|
const path = url.replace(/^file:/, '')
|
|
36
48
|
let conn: Promise<Conn> | undefined
|
|
37
49
|
const connect = () => (conn ??= open(path))
|
|
@@ -46,13 +58,15 @@ export function createDb({ url = 'file:.data/app.db', migrate }: { url?: string;
|
|
|
46
58
|
dialect: 'sqlite',
|
|
47
59
|
raw: connect,
|
|
48
60
|
query,
|
|
49
|
-
exec: async text => (await connect()).exec(text),
|
|
61
|
+
exec: async (text) => (await connect()).exec(text),
|
|
50
62
|
async transaction(fn) {
|
|
51
63
|
if (inside.getStore()) return fn({ query })
|
|
52
64
|
const c = await connect()
|
|
53
65
|
const before = gate
|
|
54
66
|
let release!: () => void
|
|
55
|
-
gate = new Promise<void>(ok => {
|
|
67
|
+
gate = new Promise<void>((ok) => {
|
|
68
|
+
release = ok
|
|
69
|
+
})
|
|
56
70
|
await before
|
|
57
71
|
try {
|
|
58
72
|
c.exec('begin')
|
|
@@ -68,7 +82,7 @@ export function createDb({ url = 'file:.data/app.db', migrate }: { url?: string;
|
|
|
68
82
|
release()
|
|
69
83
|
}
|
|
70
84
|
},
|
|
71
|
-
locked: fn => fn(),
|
|
85
|
+
locked: (fn) => fn(),
|
|
72
86
|
}
|
|
73
87
|
return createNative(driver, { migrate })
|
|
74
88
|
}
|
package/src/prisma-client.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
// Prisma
|
|
1
|
+
// Prisma: `withWeft(client, dialect)` returns the same client with the handle the server
|
|
2
2
|
// migrates and Q011 reads. Migrations are `prisma/migrations`, applied here because `prisma migrate deploy` isn't in a
|
|
3
3
|
// production bundle. Interactive transactions keep the lock and the statements on one connection. It stays Prisma's own
|
|
4
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.
|
|
@@ -13,39 +13,68 @@ interface Tx {
|
|
|
13
13
|
}
|
|
14
14
|
interface Client extends Tx {
|
|
15
15
|
$on?(event: 'query', fn: (e: { query: string }) => void): void
|
|
16
|
-
$transaction<T>(
|
|
16
|
+
$transaction<T>(
|
|
17
|
+
fn: (tx: Tx) => Promise<T>,
|
|
18
|
+
options: { maxWait: number; timeout: number },
|
|
19
|
+
): Promise<T>
|
|
17
20
|
}
|
|
18
21
|
|
|
19
22
|
const LOCK = 7_301_988
|
|
20
|
-
const statements = (sql: string) =>
|
|
23
|
+
const statements = (sql: string) =>
|
|
24
|
+
sql
|
|
25
|
+
.split(/;[ \t]*(?:\r?\n|$)/)
|
|
26
|
+
.map((s) => s.replace(/^(\s*--.*\n)+/, '').trim())
|
|
27
|
+
.filter(Boolean)
|
|
21
28
|
|
|
22
29
|
/** The same client, with `$weft`: the handle the server migrates and Q011 reads. */
|
|
23
|
-
export function withWeft<C extends object>(
|
|
30
|
+
export function withWeft<C extends object>(
|
|
31
|
+
client: C,
|
|
32
|
+
dialect: Dialect,
|
|
33
|
+
{ migrate = true }: { migrate?: boolean | undefined } = {},
|
|
34
|
+
): C & { $weft: DbHandle } {
|
|
24
35
|
const c = client as unknown as Client
|
|
25
36
|
const handle: DbHandle = {
|
|
26
37
|
dialect,
|
|
27
38
|
exempt: new Set(),
|
|
28
|
-
migrate: (list: Migration[]) =>
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
39
|
+
migrate: (list: Migration[]) =>
|
|
40
|
+
c.$transaction(
|
|
41
|
+
async (tx) => {
|
|
42
|
+
if (dialect === 'postgresql')
|
|
43
|
+
await tx.$queryRawUnsafe(`select pg_advisory_xact_lock(${LOCK})::text`)
|
|
44
|
+
if (dialect === 'mysql') {
|
|
45
|
+
const got = await tx.$queryRawUnsafe<{ got: unknown }[]>(
|
|
46
|
+
`select get_lock('weft_migrations', 60) as got`,
|
|
47
|
+
)
|
|
48
|
+
if (Number(got[0]?.got) !== 1)
|
|
49
|
+
throw new Error('another instance held the migration lock for 60s')
|
|
50
|
+
}
|
|
51
|
+
try {
|
|
52
|
+
await tx.$executeRawUnsafe(
|
|
53
|
+
'create table if not exists __weft_migrations (tag varchar(255) primary key)',
|
|
54
|
+
)
|
|
55
|
+
const done = new Set(
|
|
56
|
+
(
|
|
57
|
+
await tx.$queryRawUnsafe<{ tag: string }[]>('select tag from __weft_migrations')
|
|
58
|
+
).map((r) => r.tag),
|
|
59
|
+
)
|
|
60
|
+
for (const m of list) {
|
|
61
|
+
if (done.has(m.tag)) continue
|
|
62
|
+
for (const s of statements(m.sql)) await tx.$executeRawUnsafe(s)
|
|
63
|
+
await tx.$executeRawUnsafe(
|
|
64
|
+
`insert into __weft_migrations (tag) values (${dialect === 'postgresql' ? '$1' : '?'})`,
|
|
65
|
+
m.tag,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
} finally {
|
|
69
|
+
// MySQL's lock belongs to the connection, not the transaction: a failed run would keep it in the pool.
|
|
70
|
+
if (dialect === 'mysql')
|
|
71
|
+
await tx.$queryRawUnsafe(`select release_lock('weft_migrations')`).catch(() => {})
|
|
72
|
+
}
|
|
73
|
+
},
|
|
74
|
+
{ maxWait: 60_000, timeout: 120_000 },
|
|
75
|
+
),
|
|
47
76
|
}
|
|
48
|
-
c.$on?.('query', e => touch(handle, e.query))
|
|
77
|
+
c.$on?.('query', (e) => touch(handle, e.query))
|
|
49
78
|
;(runtime.dbs ??= new Set()).add(gate(handle, migrate))
|
|
50
79
|
return register(client, handle)
|
|
51
80
|
}
|
package/src/prisma.ts
CHANGED
|
@@ -1,7 +1,17 @@
|
|
|
1
1
|
// A list source over a Prisma model delegate: `prismaSource(ctx.db.task, { columns })`. It only reads what a delegate
|
|
2
2
|
// has, so it needs nothing from `@prisma/client` and works with any generated client.
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
pageOf,
|
|
6
|
+
plan,
|
|
7
|
+
rowOf,
|
|
8
|
+
storedId,
|
|
9
|
+
written,
|
|
10
|
+
type DbAdapter,
|
|
11
|
+
type ListParams,
|
|
12
|
+
type ListSource,
|
|
13
|
+
type SourceSpec,
|
|
14
|
+
} from '@weft-js/start/list'
|
|
5
15
|
|
|
6
16
|
/** What a generated client's model has, whatever its schema. */
|
|
7
17
|
export interface PrismaDelegate {
|
|
@@ -21,42 +31,106 @@ export interface PrismaSpec extends SourceSpec {
|
|
|
21
31
|
|
|
22
32
|
const MISSING = 'P2025'
|
|
23
33
|
|
|
34
|
+
type PrismaList<Row> = {
|
|
35
|
+
model: PrismaDelegate
|
|
36
|
+
spec: PrismaSpec
|
|
37
|
+
select: Record<string, boolean>
|
|
38
|
+
idColumn: string
|
|
39
|
+
reading: (row: object) => Row
|
|
40
|
+
escaped: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function listRows<Row>(p: ListParams, context: PrismaList<Row>) {
|
|
44
|
+
const { model, spec, select, idColumn, reading, escaped } = context
|
|
45
|
+
const q = plan(spec, p)
|
|
46
|
+
const and: object[] = q.where.map((w) => {
|
|
47
|
+
const some = w.values.filter((v) => v !== null)
|
|
48
|
+
return {
|
|
49
|
+
OR: [
|
|
50
|
+
...(some.length ? [{ [w.column]: { in: some } }] : []),
|
|
51
|
+
...(w.values.includes(null) ? [{ [w.column]: null }] : []),
|
|
52
|
+
],
|
|
53
|
+
}
|
|
54
|
+
})
|
|
55
|
+
if (q.search) {
|
|
56
|
+
const term = q.search.text.replace(/[\\%_]/g, '\\$&')
|
|
57
|
+
and.push({
|
|
58
|
+
OR: q.search.columns.map((c) => ({
|
|
59
|
+
[c]: { contains: term, ...(escaped ? { mode: 'insensitive' } : {}) },
|
|
60
|
+
})),
|
|
61
|
+
})
|
|
62
|
+
}
|
|
63
|
+
const where = { AND: and }
|
|
64
|
+
const dir = q.sort?.dir === 'desc' ? 'desc' : 'asc',
|
|
65
|
+
cmp = dir === 'desc' ? 'lt' : 'gt'
|
|
66
|
+
const keyset = p.after
|
|
67
|
+
? q.sort
|
|
68
|
+
? {
|
|
69
|
+
OR: [
|
|
70
|
+
{ [q.sort.column]: { [cmp]: p.after[0] } },
|
|
71
|
+
{ [q.sort.column]: p.after[0], [idColumn]: { [cmp]: p.after[1] } },
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
: { [idColumn]: { gt: p.after[1] } }
|
|
75
|
+
: undefined
|
|
76
|
+
const orderBy = q.sort ? [{ [q.sort.column]: dir }, { [idColumn]: dir }] : [{ [idColumn]: 'asc' }]
|
|
77
|
+
const cursor = p.mode === 'cursor'
|
|
78
|
+
const rows = (
|
|
79
|
+
await model.findMany({
|
|
80
|
+
select,
|
|
81
|
+
where: keyset ? { AND: [where, keyset] } : where,
|
|
82
|
+
orderBy,
|
|
83
|
+
take: cursor ? p.limit + 1 : p.limit,
|
|
84
|
+
skip: p.offset,
|
|
85
|
+
})
|
|
86
|
+
).map(reading)
|
|
87
|
+
return pageOf(
|
|
88
|
+
rows,
|
|
89
|
+
p,
|
|
90
|
+
{ sort: q.sort?.field, id: q.id.field },
|
|
91
|
+
cursor ? undefined : await model.count({ where }),
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
24
95
|
/** A `ListSource` over one Prisma model. */
|
|
25
|
-
export function prismaSource<Row = Record<string, unknown>>(
|
|
26
|
-
|
|
96
|
+
export function prismaSource<Row = Record<string, unknown>>(
|
|
97
|
+
model: PrismaDelegate,
|
|
98
|
+
spec: PrismaSpec,
|
|
99
|
+
): ListSource<Row> {
|
|
100
|
+
const select = Object.fromEntries(Object.values(spec.columns).map((c) => [c, true]))
|
|
27
101
|
const reading = rowOf<Row>(spec)
|
|
28
102
|
const idColumn = storedId(spec)
|
|
29
|
-
const missing = (e: unknown) => {
|
|
103
|
+
const missing = (e: unknown) => {
|
|
104
|
+
if ((e as { code?: string } | null)?.code !== MISSING) throw e
|
|
105
|
+
return null
|
|
106
|
+
}
|
|
30
107
|
// Prisma leaves `%` and `_` as wildcards in `contains`, so a term with either is checked again here.
|
|
31
108
|
const escaped = spec.insensitive !== false
|
|
32
109
|
return {
|
|
33
110
|
limits: { search: 'cased' },
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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 }))
|
|
111
|
+
list(p: ListParams) {
|
|
112
|
+
return listRows(p, { model, spec, select, idColumn, reading, escaped })
|
|
113
|
+
},
|
|
114
|
+
get: async (id) => {
|
|
115
|
+
const r = await model.findUnique({ where: { [idColumn]: id }, select })
|
|
116
|
+
return r ? reading(r) : null
|
|
53
117
|
},
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
update: (id, patch) => {
|
|
57
|
-
|
|
118
|
+
create: async (data) =>
|
|
119
|
+
reading(await model.create({ data: written(spec, data, true), select })),
|
|
120
|
+
update: (id, patch) => {
|
|
121
|
+
const data = written(spec, patch)
|
|
122
|
+
return model.update({ where: { [idColumn]: id }, data, select }).then(reading, missing)
|
|
123
|
+
},
|
|
124
|
+
remove: (id) =>
|
|
125
|
+
model.delete({ where: { [idColumn]: id }, select: { [idColumn]: true } }).then(
|
|
126
|
+
() => true,
|
|
127
|
+
(e) => missing(e) ?? false,
|
|
128
|
+
),
|
|
58
129
|
}
|
|
59
130
|
}
|
|
60
131
|
|
|
61
132
|
/** `db.adapter@1` over a Prisma model. */
|
|
62
|
-
export const adapter: DbAdapter<PrismaDelegate> = {
|
|
133
|
+
export const adapter: DbAdapter<PrismaDelegate> = {
|
|
134
|
+
name: 'prisma',
|
|
135
|
+
source: (t, spec) => prismaSource(t, spec),
|
|
136
|
+
}
|
package/src/sql.ts
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
// A list source from bound SQL: `columns` is required, identifiers come only from it, and every value
|
|
2
2
|
// is a parameter. `target` is anything with `query(text, params)`: `ctx.db` from `@weft-js/db/plugin-native`, or a driver.
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
likeOf,
|
|
6
|
+
pageOf,
|
|
7
|
+
plan,
|
|
8
|
+
rowOf,
|
|
9
|
+
storedId,
|
|
10
|
+
written,
|
|
11
|
+
type DbAdapter,
|
|
12
|
+
type ListParams,
|
|
13
|
+
type ListSource,
|
|
14
|
+
type SourceSpec,
|
|
15
|
+
} from '@weft-js/start/list'
|
|
5
16
|
import { StartError } from '@weft-js/start/data'
|
|
6
17
|
|
|
7
18
|
/** Whatever runs a bound statement, and says which dialect it speaks. */
|
|
@@ -12,20 +23,101 @@ export interface SqlTarget {
|
|
|
12
23
|
}
|
|
13
24
|
|
|
14
25
|
/** A source's allowlist, and the table it covers. */
|
|
15
|
-
export interface SqlSpec extends SourceSpec {
|
|
26
|
+
export interface SqlSpec extends SourceSpec {
|
|
27
|
+
table: string
|
|
28
|
+
}
|
|
16
29
|
|
|
17
30
|
const IDENT = /^[A-Za-z_][A-Za-z0-9_]*$/
|
|
18
31
|
|
|
19
32
|
function ident(dialect: string) {
|
|
20
33
|
const q = dialect === 'mysql' ? '`' : '"'
|
|
21
34
|
return (name: string) => {
|
|
22
|
-
if (!IDENT.test(name))
|
|
35
|
+
if (!IDENT.test(name))
|
|
36
|
+
throw new StartError(
|
|
37
|
+
'Q002',
|
|
38
|
+
`\`${name}\` is not a plain identifier, so it can't go in SQL`,
|
|
39
|
+
'use a column name',
|
|
40
|
+
)
|
|
23
41
|
return `${q}${name}${q}`
|
|
24
42
|
}
|
|
25
43
|
}
|
|
26
44
|
|
|
45
|
+
function listSql<Row>({
|
|
46
|
+
target,
|
|
47
|
+
spec,
|
|
48
|
+
q,
|
|
49
|
+
bind,
|
|
50
|
+
read,
|
|
51
|
+
table,
|
|
52
|
+
select,
|
|
53
|
+
idColumn,
|
|
54
|
+
}: {
|
|
55
|
+
target: SqlTarget
|
|
56
|
+
spec: SqlSpec
|
|
57
|
+
q: (name: string) => string
|
|
58
|
+
bind: (params: unknown[]) => (value: unknown) => string
|
|
59
|
+
read: (text: string, params: unknown[]) => Promise<Row[]>
|
|
60
|
+
table: string
|
|
61
|
+
select: string
|
|
62
|
+
idColumn: string
|
|
63
|
+
}): ListSource<Row>['list'] {
|
|
64
|
+
return async function list(p: ListParams) {
|
|
65
|
+
const qp = plan(spec, p)
|
|
66
|
+
const params: unknown[] = []
|
|
67
|
+
const put = bind(params)
|
|
68
|
+
const parts = qp.where.map((w) => {
|
|
69
|
+
const some = w.values.filter((v) => v !== null)
|
|
70
|
+
const hit =
|
|
71
|
+
some.length === 1
|
|
72
|
+
? `${q(w.column)} = ${put(some[0])}`
|
|
73
|
+
: some.length
|
|
74
|
+
? `${q(w.column)} in (${some.map(put).join(', ')})`
|
|
75
|
+
: ''
|
|
76
|
+
return w.values.includes(null) ? `(${hit ? `${hit} or ` : ''}${q(w.column)} is null)` : hit
|
|
77
|
+
})
|
|
78
|
+
if (qp.search) {
|
|
79
|
+
const text = likeOf(qp.search.text)
|
|
80
|
+
parts.push(
|
|
81
|
+
`(${qp.search.columns.map((c) => `lower(${q(c)}) like ${put(text)} escape '!'`).join(' or ')})`,
|
|
82
|
+
)
|
|
83
|
+
}
|
|
84
|
+
const filtered = [...parts],
|
|
85
|
+
mark = params.length // what the count binds: the keyset isn't part of it
|
|
86
|
+
const cmp = qp.sort?.dir === 'desc' ? '<' : '>'
|
|
87
|
+
if (p.after) {
|
|
88
|
+
const [value, id] = p.after
|
|
89
|
+
const col = qp.sort && q(qp.sort.column)
|
|
90
|
+
parts.push(
|
|
91
|
+
col
|
|
92
|
+
? `(${col} ${cmp} ${put(value)} or (${col} = ${put(value)} and ${idColumn} ${cmp} ${put(id)}))`
|
|
93
|
+
: `${idColumn} > ${put(id)}`,
|
|
94
|
+
)
|
|
95
|
+
}
|
|
96
|
+
const where = (conditions: string[]) =>
|
|
97
|
+
conditions.length ? ` where ${conditions.join(' and ')}` : ''
|
|
98
|
+
const dir = qp.sort?.dir === 'desc' ? 'desc' : 'asc'
|
|
99
|
+
const order = qp.sort ? `${q(qp.sort.column)} ${dir}, ${idColumn} ${dir}` : `${idColumn} asc`
|
|
100
|
+
const cursor = p.mode === 'cursor'
|
|
101
|
+
const counted = params.slice(0, mark)
|
|
102
|
+
const rows = await read(
|
|
103
|
+
`select ${select} from ${table}${where(parts)} order by ${order} limit ${Math.trunc(cursor ? p.limit + 1 : p.limit)} offset ${Math.trunc(p.offset)}`,
|
|
104
|
+
params,
|
|
105
|
+
)
|
|
106
|
+
const total = cursor
|
|
107
|
+
? undefined
|
|
108
|
+
: Number(
|
|
109
|
+
(await target.query(`select count(*) as n from ${table}${where(filtered)}`, counted))[0]!
|
|
110
|
+
.n,
|
|
111
|
+
)
|
|
112
|
+
return pageOf(rows, p, { sort: qp.sort?.field, id: qp.id.field }, total)
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
27
116
|
/** A `ListSource` over one table, as bound SQL. */
|
|
28
|
-
export function sqlSource<Row = Record<string, unknown>>(
|
|
117
|
+
export function sqlSource<Row = Record<string, unknown>>(
|
|
118
|
+
target: SqlTarget,
|
|
119
|
+
spec: SqlSpec,
|
|
120
|
+
): ListSource<Row> {
|
|
29
121
|
const dialect = target.dialect ?? target.$weft?.dialect ?? 'postgresql'
|
|
30
122
|
const q = ident(dialect)
|
|
31
123
|
for (const c of [spec.table, ...Object.values(spec.columns)]) q(c)
|
|
@@ -37,52 +129,47 @@ export function sqlSource<Row = Record<string, unknown>>(target: SqlTarget, spec
|
|
|
37
129
|
params.push(v)
|
|
38
130
|
return dialect === 'postgresql' ? `$${params.length}` : '?'
|
|
39
131
|
}
|
|
40
|
-
const read = async (text: string, params: unknown[]) =>
|
|
41
|
-
|
|
132
|
+
const read = async (text: string, params: unknown[]) =>
|
|
133
|
+
(await target.query(text, params)).map(reading)
|
|
134
|
+
const byId = (id: unknown) => {
|
|
135
|
+
const params: unknown[] = []
|
|
136
|
+
return read(
|
|
137
|
+
`select ${select} from ${table} where ${idColumn} = ${bind(params)(id)}`,
|
|
138
|
+
params,
|
|
139
|
+
).then((r) => r[0] ?? null)
|
|
140
|
+
}
|
|
42
141
|
return {
|
|
43
|
-
|
|
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
|
-
},
|
|
142
|
+
list: listSql({ target, spec, q, bind, read, table, select, idColumn }),
|
|
72
143
|
get: byId,
|
|
73
144
|
async create(data) {
|
|
74
145
|
const row = written(spec, data, true)
|
|
75
146
|
const id = data[spec.id ?? 'id']
|
|
76
|
-
if (id === undefined)
|
|
77
|
-
|
|
78
|
-
|
|
147
|
+
if (id === undefined)
|
|
148
|
+
throw new StartError(
|
|
149
|
+
'Q002',
|
|
150
|
+
"`create` needs the id: a generated one can't be read back on every dialect",
|
|
151
|
+
'generate it before the insert',
|
|
152
|
+
)
|
|
153
|
+
const keys = Object.keys(row),
|
|
154
|
+
params: unknown[] = [],
|
|
155
|
+
put = bind(params)
|
|
156
|
+
await target.query(
|
|
157
|
+
`insert into ${table} (${keys.map(q).join(', ')}) values (${keys.map((k) => put(row[k])).join(', ')})`,
|
|
158
|
+
params,
|
|
159
|
+
)
|
|
79
160
|
return (await byId(id))!
|
|
80
161
|
},
|
|
81
162
|
async update(id, patch) {
|
|
82
|
-
const row = written(spec, patch),
|
|
83
|
-
|
|
163
|
+
const row = written(spec, patch),
|
|
164
|
+
params: unknown[] = [],
|
|
165
|
+
put = bind(params)
|
|
166
|
+
const sets = Object.keys(row).map((k) => `${q(k)} = ${put(row[k])}`)
|
|
84
167
|
if (!(await byId(id))) return null
|
|
85
|
-
if (sets.length)
|
|
168
|
+
if (sets.length)
|
|
169
|
+
await target.query(
|
|
170
|
+
`update ${table} set ${sets.join(', ')} where ${idColumn} = ${put(id)}`,
|
|
171
|
+
params,
|
|
172
|
+
)
|
|
86
173
|
return byId(id)
|
|
87
174
|
},
|
|
88
175
|
async remove(id) {
|
|
@@ -95,13 +182,24 @@ export function sqlSource<Row = Record<string, unknown>>(target: SqlTarget, spec
|
|
|
95
182
|
}
|
|
96
183
|
|
|
97
184
|
/** `db.adapter@1` over bound SQL. */
|
|
98
|
-
export const adapter: DbAdapter<SqlTarget> = {
|
|
185
|
+
export const adapter: DbAdapter<SqlTarget> = {
|
|
186
|
+
name: 'sql',
|
|
187
|
+
source: (t, spec) => sqlSource(t, spec as SqlSpec),
|
|
188
|
+
}
|
|
99
189
|
|
|
100
190
|
/**
|
|
101
191
|
* A trigger so writes from anywhere (cron, psql, another service) reach `.live` queries, on the postgres bus: tags, never
|
|
102
192
|
* rows. Run the text once in a migration.
|
|
103
193
|
*/
|
|
104
|
-
export function postgresTrigger({
|
|
194
|
+
export function postgresTrigger({
|
|
195
|
+
table,
|
|
196
|
+
id = 'id',
|
|
197
|
+
tag = table,
|
|
198
|
+
}: {
|
|
199
|
+
table: string
|
|
200
|
+
id?: string
|
|
201
|
+
tag?: string
|
|
202
|
+
}): string {
|
|
105
203
|
const q = ident('postgresql')
|
|
106
204
|
if (!/^[\w:-]+$/.test(tag)) throw new StartError('Q002', `\`${tag}\` can't be a tag in a trigger`)
|
|
107
205
|
const fn = `weft_poke_${table}`
|