@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.
@@ -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('@weft-js/db/plugin-native/postgres', 'postgres', () => import('postgres'))
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({ 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[]>
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 => client.begin(async tx => {
21
- await tx.unsafe(`select pg_advisory_xact_lock(${LOCK})`)
22
- return fn()
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
  }
@@ -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 = { all: (text, params) => db.prepare(text).all(...params), exec: text => void db.run(text) }
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 = { all: (text, params) => db.prepare(text).all(...(params.map(v => (typeof v === 'boolean' ? Number(v) : v)) as SQLInputValue[])), exec: text => db.exec(text) }
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({ url = 'file:.data/app.db', migrate }: { url?: string; migrate?: boolean } = {}) {
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 => { release = 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
  }
@@ -1,4 +1,4 @@
1
- // Prisma on weft's seam (research 34 §4): `withWeft(client, dialect)` returns the same client with the handle the server
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>(fn: (tx: Tx) => Promise<T>, options: { maxWait: number; timeout: number }): Promise<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) => sql.split(/;[ \t]*(?:\r?\n|$)/).map(s => s.replace(/^(\s*--.*\n)+/, '').trim()).filter(Boolean)
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>(client: C, dialect: Dialect, { migrate = true }: { migrate?: boolean | undefined } = {}): C & { $weft: DbHandle } {
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[]) => 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 }),
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 { pageOf, plan, rowOf, storedId, written, type DbAdapter, type ListParams, type ListSource, type SourceSpec } from '@weft-js/start/list'
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>>(model: PrismaDelegate, spec: PrismaSpec): ListSource<Row> {
26
- const select = Object.fromEntries(Object.values(spec.columns).map(c => [c, true]))
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) => { if ((e as { code?: string } | null)?.code !== MISSING) throw e; return null }
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
- 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 }))
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
- 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),
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> = { name: 'prisma', source: (t, spec) => prismaSource(t, spec) }
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 { likeOf, pageOf, plan, rowOf, storedId, written, type DbAdapter, type ListParams, type ListSource, type SourceSpec } from '@weft-js/start/list'
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 { table: string }
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)) throw new StartError('Q002', `\`${name}\` is not a plain identifier, so it can't go in SQL`, 'use a column 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>>(target: SqlTarget, spec: SqlSpec): ListSource<Row> {
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[]) => (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) }
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
- 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
- },
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) 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)
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), params: unknown[] = [], put = bind(params)
83
- const sets = Object.keys(row).map(k => `${q(k)} = ${put(row[k])}`)
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) await target.query(`update ${table} set ${sets.join(', ')} where ${idColumn} = ${put(id)}`, params)
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> = { name: 'sql', source: (t, spec) => sqlSource(t, spec as SqlSpec) }
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({ table, id = 'id', tag = table }: { table: string; id?: string; tag?: string }): string {
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}`