ReviewOS

also looking at this

stacks/bun-query-builder

fix(client): keep client.d.ts free of `any`, and make the guard that says so run

#1132
Merged glennmichael123 wants to merge fix/paginate-options-any into main
3 files +62 -8
.github/workflows/ci.ymlmodified+6-0
Changes to .github/workflows/ci.yml
@@ -108,6 +108,12 @@ jobs:
108108 echo "Waiting for PostgreSQL to be ready..."
109109 timeout 60 bash -c 'until pg_isready -h localhost -p 5432 -U postgres; do sleep 1; done'
110110 echo "PostgreSQL is ready!"
111 # public-surface-types.test.ts reads dist/*.d.ts. Without this it found no
112 # dist/, returned early, and checked nothing on every CI run — which is
113 # how an `any` reached client.d.ts after #1117 took that file to zero.
114 - name: Build
115 run: bun run build
116
111117 - name: Unit Tests
112118 run: bun run test
113119
packages/bun-query-builder/src/client.tsmodified+9-3
Changes to packages/bun-query-builder/src/client.ts
@@ -640,8 +640,14 @@ export interface PaginateOptions {
640640 perPage?: number
641641 /** 1-based page number. Defaults to 1. */
642642 page?: number
643 /** Run the count and the page query inside this transaction — see #1051. */
644 tx?: { unsafe: (sql: string, params?: any[]) => any }
643 /**
644 * Run the count and the page query inside this transaction — see #1051.
645 *
646 * `unknown` rather than `any`, matching the positional overload's declared
647 * `opts.tx`. An `any` here is published in `client.d.ts`, which is the type
648 * an app touches on every query — #1117 took that file to zero and pinned it.
649 */
650 tx?: { unsafe: (sql: string, params?: unknown[]) => unknown }
645651}
646652
647653export type QueryResult = unknown
@@ -5854,7 +5860,7 @@ export function createQueryBuilder<DB extends AnyDatabaseSchema>(state?: Partial
58545860 if (typeof perPage === 'object' && perPage !== null) {
58555861 const options = perPage
58565862 page = options.page ?? 1
5857 opts = options.tx ? { tx: options.tx } : {}
5863 opts = options.tx ? { tx: options.tx as { unsafe: (sql: string, params?: any[]) => any } } : {}
58585864 perPage = options.perPage ?? 10
58595865 }
58605866 if (!Number.isFinite(perPage) || perPage <= 0 || !Number.isInteger(perPage))
packages/bun-query-builder/test/public-surface-types.test.tsmodified+47-5
Changes to packages/bun-query-builder/test/public-surface-types.test.ts
@@ -1,5 +1,5 @@
11import { describe, expect, it } from 'bun:test'
2import { existsSync, readdirSync, readFileSync } from 'node:fs'
2import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'
33import { join } from 'node:path'
44
55/**
@@ -29,18 +29,60 @@ import { join } from 'node:path'
2929 */
3030
3131const DIST = join(import.meta.dir, '..', 'dist')
32const SRC = join(import.meta.dir, '..', 'src')
3233const BASELINE = 18
3334
3435function anyCount(source: string): number {
3536 return (source.match(/:\s*any\b|<any>|any\[\]/g) ?? []).length
3637}
3738
39/**
40 * Whether `dist/` reflects the `src/` in this working tree.
41 *
42 * This guard reads a build output, so it has two failure modes that are not
43 * regressions and must not be reported as ones:
44 *
45 * - `dist/` missing entirely — nobody has built yet
46 * - `dist/` older than `src/` — built before the change under test, so the
47 * counts describe code that is no longer here. That produced a red suite
48 * twice for people whose checkout predated #1117, on a defect he had
49 * already fixed.
50 *
51 * In CI the same conditions mean the opposite: the workflow builds before
52 * testing, so a missing or stale `dist/` means the build step did not run, and
53 * skipping would be how a real leak ships unnoticed. That is exactly what
54 * happened — the CI `test` job never built, so this returned early on every
55 * run and never checked anything until the build step was added alongside
56 * this comment.
57 */
58function distState(): 'fresh' | 'missing' | 'stale' {
59 if (!existsSync(DIST))
60 return 'missing'
61 const declarations = readdirSync(DIST).filter(file => file.endsWith('.d.ts'))
62 if (declarations.length === 0)
63 return 'missing'
64 const builtAt = Math.min(...declarations.map(f => statSync(join(DIST, f)).mtimeMs))
65 const changedAt = Math.max(...readdirSync(SRC).map(f => statSync(join(SRC, f)).mtimeMs))
66 return changedAt > builtAt ? 'stale' : 'fresh'
67}
68
69const STATE = distState()
70const CI = Boolean(process.env.CI)
71
72function assertUsable(): boolean {
73 if (STATE === 'fresh')
74 return true
75 if (CI)
76 throw new Error(`[public-surface] dist/ is ${STATE} in CI — the build step must run before these tests, or this guard silently checks nothing.`)
77 // Locally: say why it did not run, rather than asserting against stale output.
78 console.warn(`[public-surface] skipped: dist/ is ${STATE}. Run \`bun run build\` to check the published types.`)
79 return false
80}
81
3882describe('the published type surface', () => {
3983 it('does not leak more `any` than the documented survivors', () => {
40 if (!existsSync(DIST)) {
41 // Types are a build output; nothing to check before `bun run build`.
84 if (!assertUsable())
4285 return
43 }
4486
4587 const declarations = readdirSync(DIST).filter(file => file.endsWith('.d.ts'))
4688 expect(declarations.length).toBeGreaterThan(0)
@@ -54,7 +96,7 @@ describe('the published type surface', () => {
5496 })
5597
5698 it('keeps the query builder itself free of them', () => {
57 if (!existsSync(join(DIST, 'client.d.ts')))
99 if (!assertUsable())
58100 return
59101
60102 // client.d.ts is the type an app touches on every single query. It went