ReviewOS

also looking at this

stacks/bun-query-builder

fix(types): config options should not require what the library already defaults

#1064
Merged glennmichael123 wants to merge fix/config-options-deep-partial into main
12 files +885 -117
README.mdmodified+21-0
Changes to README.md
@@ -81,6 +81,27 @@ const q = db
8181const rows = await q.execute()
8282```
8383
84### Configuration
85
86Drop a `query-builder.config.ts` in your project root. Every field is optional at
87every depth — supply only what you want to change, and the library defaults the rest:
88
89```ts
90import { defineConfig } from 'bun-query-builder'
91
92export default defineConfig({
93 dialect: 'postgres',
94 database: { database: 'my_app' },
95})
96```
97
98```ts
99// Load it once at app boot, or configure from code with setConfig().
100import { getConfig } from 'bun-query-builder'
101
102await getConfig()
103```
104
84105### Aggregations
85106
86107```ts
docs/advanced/api.mdmodified+23-5
Changes to docs/advanced/api.md
@@ -7,7 +7,7 @@ This page summarizes method signatures and brief descriptions. See feature pages
77## Imports
88
99```ts
10import type { CursorPaginationResult, DatabaseSchema, PaginationResult, QueryBuilderConfig, SchemaMeta, SortDirection, TransactionOptions, WhereExpression, WhereOperator } from 'bun-query-builder'
10import type { CursorPaginationResult, DatabaseSchema, PaginationResult, QueryBuilderOptions, SchemaMeta, SortDirection, TransactionOptions, WhereExpression, WhereOperator } from 'bun-query-builder'
1111import {
1212 // Schema and Model Definition
1313 buildDatabaseSchema,
@@ -41,10 +41,26 @@ const db = createQueryBuilder<typeof schema>({ schema, meta })
4141
4242## Configuration
4343
44- config: Global runtime config (merged via bunfig). Shape: `QueryBuilderConfig`
45- defaultConfig: Library defaults
44- `defineConfig(options)`: identity helper for a typed `query-builder.config.ts`
45- `setConfig(options)`: deep-merges `QueryBuilderOptions` into the live config
46- `getConfig()`: loads `query-builder.config.ts` and `QUERY_BUILDER_*` env vars once, merges them in, returns the resolved config
47- `config`: the live process-wide config, typed `QueryBuilderConfig` — every field is present, so `config.pagination.defaultPerPage` needs no guard
48- `defaultConfig`: the library defaults, same resolved shape
4649
47Key sections: `dialect`, `timestamps`, `pagination`, `aliasing`, `relations`, `transactionDefaults`, `sql`, `features`, `debug`.
50Two types, two jobs. **Write** `QueryBuilderOptions`: every field optional at every
51depth, so you supply only what you want to change. **Read** `QueryBuilderConfig`:
52your options merged over the defaults, every field present. Annotating your own
53config with the resolved type is what made every field added here a compile error
54downstream ([#1062](https://github.com/stacksjs/bun-query-builder/issues/1062)).
55
56Key sections: `dialect`, `database`, `snapshotDir`, `migrationDir`, `timestamps`, `pagination`, `aliasing`, `relations`, `transactionDefaults`, `sql`, `features`, `softDeletes`, `hooks`, `debug`.
57
58Naming one leaf keeps the rest of its section:
59
60```ts
61setConfig({ transactionDefaults: { retries: 5 } })
62// retries is 5; sqlStates and backoff keep their defaults
63```
4864
4965## Schema & Models
5066
@@ -276,7 +292,9 @@ await db
276292
277293- WhereOperator: '=', '!=', '<', '>', '<=', '>=', 'like', 'in', 'not in', 'is', 'is not'
278294- WhereExpression<TColumns>: object | tuple | raw
279- QueryBuilderConfig: see Configuration section
295- QueryBuilderOptions: user-supplied configuration — every field optional, recursively. What you write in `query-builder.config.ts` or pass to `setConfig()`
296- QueryBuilderConfig: defaults merged with your configuration — every field present. What `config`, `defaultConfig` and `getConfig()` give you
297- DeepPartial&lt;T&gt;: the utility behind `QueryBuilderOptions`
280298- DatabaseSchema<ModelRecord>
281299
282300---
docs/advanced/dialects.mdmodified+3-1
Changes to docs/advanced/dialects.md
@@ -472,7 +472,9 @@ function trackConfigurationChange(key: string, oldValue: any, newValue: any) {
472472
473473### How do per-instance overrides work
474474
475Call `db.configure(partialConfig)` to shallow-merge selected properties for that builder instance.
475Call `db.configure(options)` to deep-merge selected properties — naming one leaf of a
476section keeps that section's other defaults. Note there is no per-instance config yet:
477this writes the same process-wide config `setConfig()` does.
476478
477479### Can I change config at runtime
478480
docs/config.mdmodified+59-70
Changes to docs/config.md
@@ -1,93 +1,82 @@
11# Configuration
22
3The bun-query-builder configuration system allows you to customize behavior across dialects, performance settings, and development preferences. All defaults can be overridden via the global `config` object.
3The bun-query-builder configuration system allows you to customize behavior across dialects, performance settings, and development preferences.
4
5**Nothing is ever required.** Every field is optional, at every depth — whatever you
6leave out keeps the library default, so fields added in future releases never become
7your problem.
48
59## Quick Start
610
11### A config file
12
13`query-builder.config.ts` in your project root, picked up by `getConfig()`:
14
715```ts
8import { config } from 'bun-query-builder'
16import { defineConfig } from 'bun-query-builder'
917
10// Basic PostgreSQL configuration
11config.dialect = 'postgres'
12config.sql.randomFunction = 'RANDOM()'
13config.sql.sharedLockSyntax = 'FOR SHARE'
14config.sql.jsonContainsMode = 'operator'
15config.aliasing.relationColumnAliasFormat = 'table_column'
16config.transactionDefaults.retries = 2
18export default defineConfig({
19 dialect: 'postgres',
20 database: { database: 'my_app' },
21})
1722```
1823
19## Configuration Interface
20
2124```ts
22export interface QueryBuilderConfig {
23 // General settings
24 verbose: boolean
25 dialect: 'postgres' | 'mysql' | 'sqlite'
26
27 // Timestamp handling
28 timestamps: {
29 createdAt: string
30 updatedAt: string
31 defaultOrderColumn: string
32 }
25// Then, once at app boot:
26import { getConfig } from 'bun-query-builder'
3327
34 // Pagination defaults
35 pagination: {
36 defaultPerPage: number
37 cursorColumn: string
38 }
28await getConfig() // applies the config file + QUERY_BUILDER_* env vars
29```
3930
40 // Column aliasing for relations
41 aliasing: {
42 relationColumnAliasFormat: 'table_column' | 'table.dot.column' | 'camelCase'
43 }
31Loading is explicit and async on purpose: the builder otherwise runs off the
32synchronous config singleton, so an early query can never race a background load.
4433
45 // Relationship configuration
46 relations: {
47 foreignKeyFormat: 'singularParent_id' | 'parentId'
48 singularizeStrategy?: 'stripTrailingS' | 'none'
49 }
34### From application code
5035
51 // Transaction behavior
52 transactionDefaults: {
53 retries: number
54 isolation?: 'read committed' | 'repeatable read' | 'serializable'
55 sqlStates: string[]
56 backoff: {
57 baseMs: number
58 factor: number
59 maxMs: number
60 jitter: boolean
61 }
62 }
36`setConfig()` deep-merges, and outranks the config file:
6337
64 // SQL dialect-specific settings
65 sql: {
66 randomFunction?: 'RANDOM()' | 'RAND()'
67 sharedLockSyntax?: 'FOR SHARE' | 'LOCK IN SHARE MODE'
68 jsonContainsMode?: 'operator' | 'function'
69 }
38```ts
39import { setConfig } from 'bun-query-builder'
7040
71 // Feature toggles
72 features: {
73 distinctOn: boolean
74 }
41setConfig({ dialect: 'sqlite', pagination: { defaultPerPage: 50 } })
7542
76 // Development and debugging
77 debug?: {
78 captureText: boolean
79 }
43// Naming one leaf keeps the rest of its section — retries becomes 5,
44// while sqlStates and backoff keep their defaults.
45setConfig({ transactionDefaults: { retries: 5 } })
46```
8047
81 // Query lifecycle hooks
82 hooks?: {
83 onQueryStart?: (context: QueryContext) => void
84 onQueryEnd?: (context: QueryContext & { durationMs: number, rowCount: number }) => void
85 onQueryError?: (context: QueryContext & { error: Error }) => void
86 startSpan?: (name: string) => { end: () => void }
87 }
88}
48Precedence is `defaults < config file < setConfig()`.
49
50### Mutating the live config
51
52```ts
53import { config } from 'bun-query-builder'
54
55config.dialect = 'postgres'
56config.sql.randomFunction = 'RANDOM()'
57config.aliasing.relationColumnAliasFormat = 'table_column'
8958```
9059
60Note that assigning a whole section (`config.sql = { … }`) replaces it rather than
61merging — prefer `setConfig()` unless you mean to drop the other keys.
62
63## The two config types
64
65| Type | Role |
66| --- | --- |
67| `QueryBuilderOptions` | What you **write**. Every field optional, recursively. Use it for `query-builder.config.ts`, `setConfig()` and `db.configure()`. |
68| `QueryBuilderConfig` | What you **read**. Your options merged over the defaults, every field present — the type of `config`, `defaultConfig` and `getConfig()`'s result. |
69
70Annotate your own configuration with `QueryBuilderOptions` (or skip the decision
71entirely and use `defineConfig`). Declaring it against the resolved
72`QueryBuilderConfig` is what made every field added to this package a compile error
73in downstream apps — see
74[#1062](https://github.com/stacksjs/bun-query-builder/issues/1062).
75
76The authoritative field list ships in the package's type declarations, so your editor
77completes it inline; this page deliberately no longer duplicates the interface, since
78a second hand-maintained copy of the config shape drifts exactly the way the types did.
79
9180## Environment-Specific Configurations
9281
9382### Production PostgreSQL Setup
packages/bun-query-builder/README.mdmodified+24-0
Changes to packages/bun-query-builder/README.md
@@ -45,6 +45,30 @@ await QueryBuilder
4545 .execute()
4646```
4747
48## Configuration
49
50Drop a `query-builder.config.ts` in your project root. Every field is optional at
51every depth — supply only what you want to change, and the library defaults the rest:
52
53```typescript
54import { defineConfig } from 'bun-query-builder'
55
56export default defineConfig({
57 dialect: 'postgres',
58 database: { database: 'my_app' },
59})
60```
61
62```typescript
63// Load it once at app boot, or configure from code with setConfig().
64import { getConfig } from 'bun-query-builder'
65
66await getConfig()
67```
68
69Type your own configuration against `QueryBuilderOptions` (what you write, everything
70optional), not `QueryBuilderConfig` (what the library reads back, everything present).
71
4872## Features
4973
5074- **Typed from Models** - Infer tables, columns, and primary keys from your model files
packages/bun-query-builder/build.tsmodified+7-0
Changes to packages/bun-query-builder/build.ts
@@ -4,6 +4,12 @@ import { join, resolve } from 'node:path'
44import process from 'node:process'
55import { dts } from 'bun-plugin-dtsx'
66
7// `ts/no-top-level-await` guards the SHIPPED entry, because a top-level await
8// anywhere in the bundle makes `bun build --compile` refuse every consumer that
9// requires it — which is the exact failure `assertCompilable()` below exists to
10// catch. This file is not shipped: it is a script Bun runs directly, where a
11// top-level await is the only way to await at all.
12// eslint-disable-next-line ts/no-top-level-await
713const result = await Bun.build({
814 entrypoints: ['src/index.ts', 'src/browser.ts', 'src/dynamodb/index.ts', 'bin/cli.ts'],
915 outdir: './dist',
@@ -70,4 +76,5 @@ async function assertCompilable(entry: string): Promise<void> {
7076 process.exit(1)
7177}
7278
79// eslint-disable-next-line ts/no-top-level-await -- build script, not shipped; see the note above
7380await assertCompilable('./dist/src/index.js')
packages/bun-query-builder/src/__tests__/config-options-compile.tsadded+153-0
Changes to packages/bun-query-builder/src/__tests__/config-options-compile.ts
@@ -0,0 +1,153 @@
1/**
2 * Compile-time verification of `QueryBuilderOptions` — the type consumers
3 * annotate their configuration with.
4 *
5 * This file is NOT executed; it's checked by `bun --bun tsc --noEmit`. Lines
6 * marked @ts-expect-error MUST fail to compile, every other line MUST compile.
7 * (`test/` is excluded from the typecheck, so config type coverage has to live
8 * here under `src/` to be enforced at all.)
9 *
10 * The point of the file is stacksjs/bun-query-builder#1062: adding a field to
11 * the resolved `QueryBuilderConfig` was a compile error in every downstream
12 * app, because the resolved type was also the only one exported for consumers
13 * to declare against. Section 1 is the regression pin — a config naming ONE
14 * key must compile, at any depth. Sections 2-4 pin the parts a naive
15 * `DeepPartial` silently breaks; section 5 pins that being optional did not
16 * cost us any real type safety.
17 */
18
19import type { QueryBuilderConfig, QueryBuilderOptions } from '../types'
20import { defineConfig, setConfig } from '../config'
21
22// ---------------------------------------------------------------------------
23// 1. Nothing is required, at any depth
24// ---------------------------------------------------------------------------
25
26// The empty config: the library supplies everything.
27export const empty: QueryBuilderOptions = {}
28
29// One top-level key. `migrationDir` is the field whose arrival broke #1062.
30export const oneKey: QueryBuilderOptions = { dialect: 'sqlite' }
31export const migrationOnly: QueryBuilderOptions = { migrationDir: 'db/migrations' }
32
33// One leaf of a REQUIRED nested section. Plain `Partial` stops here: it would
34// demand a complete `TimestampConfig`/`TransactionDefaultsConfig`.
35export const oneLeaf: QueryBuilderOptions = { timestamps: { createdAt: 'inserted_at' } }
36export const oneRetry: QueryBuilderOptions = { transactionDefaults: { retries: 5 } }
37
38// One leaf of an OPTIONAL nested section. These four are the sharpest pin in
39// the file: a naive `{ [K in keyof T]?: T[K] extends object ? … : T[K] }`
40// leaves optional members unpartialized — `T[K]` is `X | undefined`, which is
41// not a naked type parameter and so does not distribute, fails `extends object`
42// and passes through whole — and every one of these still errors.
43export const softOnly: QueryBuilderOptions = { softDeletes: { enabled: true } }
44export const emptyVitess: QueryBuilderOptions = { vitess: {} }
45export const emptyDebug: QueryBuilderOptions = { debug: {} }
46export const browserLeaf: QueryBuilderOptions = { browser: { timeout: 1000 } }
47
48// Two levels down.
49export const deepLeaf: QueryBuilderOptions = { transactionDefaults: { backoff: { jitter: false } } }
50export const poolLeaf: QueryBuilderOptions = { database: { pool: { max: 20 } } }
51
52// The call sites take it too.
53setConfig({ pagination: { defaultPerPage: 50 } })
54export const defined = defineConfig({ dialect: 'postgres', database: { database: 'my_app' } })
55
56// ---------------------------------------------------------------------------
57// 2. Functions stay callable
58// ---------------------------------------------------------------------------
59
60// A mapped type only copies PROPERTIES, and a call signature is not one.
61// Without the function guard clause `keyof (() => void)` is `never`, these
62// hooks collapse to an empty object type, and the calls below stop compiling.
63export const hooked: QueryBuilderOptions = {
64 hooks: {
65 onQueryStart: (event) => { void event.sql },
66 onQueryEnd: (event) => { void event.durationMs },
67 },
68}
69hooked.hooks?.onQueryStart?.({ sql: 'select 1' })
70
71// Generic signatures survive too — `transformResponse` is `<T>(r: any) => T`,
72// and a mapped type would erase the type parameter along with the signature.
73type TransformResponse = NonNullable<NonNullable<QueryBuilderOptions['browser']>['transformResponse']>
74export const transform: TransformResponse = (response: any) => response
75export const transformed: number[] = transform<number[]>({})
76
77// ---------------------------------------------------------------------------
78// 3. Arrays stay arrays
79// ---------------------------------------------------------------------------
80
81// Recursing into `string[]` yields `(string | undefined)[]`, which no longer
82// assigns back out to an array of `string` — so the read-backs are the pin.
83// Arrays are replaced wholesale by the merge, so a partial array was never
84// meaningful.
85export const states: QueryBuilderOptions = { transactionDefaults: { sqlStates: ['40001'] } }
86export const statesOut: readonly string[] = states.transactionDefaults!.sqlStates!
87export const pragmas: QueryBuilderOptions = { sqlite: { pragmas: ['PRAGMA foreign_keys = ON'] } }
88export const pragmasOut: readonly string[] = pragmas.sqlite!.pragmas!
89
90// Array leaves are `readonly`, so the idiomatic `as const` config file assigns.
91// A mutable `string[]` still assigns too — `readonly E[]` is the wider type.
92export const asConst: QueryBuilderOptions = {
93 dialect: 'sqlite',
94 transactionDefaults: { sqlStates: ['40001', '40P01'] },
95} as const
96
97const sharedStates: readonly string[] = ['40001']
98export const hoisted: QueryBuilderOptions = { transactionDefaults: { sqlStates: sharedStates } }
99
100const mutableStates: string[] = ['40001']
101export const fromMutable: QueryBuilderOptions = { transactionDefaults: { sqlStates: mutableStates } }
102
103// ---------------------------------------------------------------------------
104// 4. Record bags stay intact
105// ---------------------------------------------------------------------------
106
107const headers: Record<string, string> = { authorization: 'Bearer x' }
108export const withHeaders: QueryBuilderOptions = { browser: { baseUrl: '/api', headers } }
109// The read-back is the pin, exactly as for arrays: recursing into the bag would
110// rewrite it as `{ [x: string]: string | undefined }`, which does not assign
111// back out to `Record<string, string>`. Assigning INTO the bag is safe either
112// way, so testing only that direction would leave the guard unpinned.
113export const headersOut: Record<string, string> = withHeaders.browser!.headers!
114
115// ---------------------------------------------------------------------------
116// 5. Optional is not the same as unchecked
117// ---------------------------------------------------------------------------
118
119// @ts-expect-error - 'oracle' is not a SupportedDialect
120export const badDialect: QueryBuilderOptions = { dialect: 'oracle' }
121
122// @ts-expect-error - createdAt is a column name, not a number
123export const badLeafType: QueryBuilderOptions = { timestamps: { createdAt: 42 } }
124
125// @ts-expect-error - typo in a nested key is still an excess property
126export const typo: QueryBuilderOptions = { timestamps: { craetedAt: 'created_at' } }
127
128// @ts-expect-error - defaultPerPage is a number
129export const badPagination: QueryBuilderOptions = { pagination: { defaultPerPage: 'lots' } }
130
131// @ts-expect-error - foreignKeyFormat is a closed union
132export const badRelations: QueryBuilderOptions = { relations: { foreignKeyFormat: 'nope' } }
133
134// @ts-expect-error - a hook must be callable, not a number
135export const badHook: QueryBuilderOptions = { hooks: { onQueryStart: 42 } }
136
137// @ts-expect-error - headers values are strings
138export const badHeaders: QueryBuilderOptions = { browser: { headers: { a: 1 } } }
139
140// ---------------------------------------------------------------------------
141// 6. The options type is a strict WIDENING of what setConfig used to take
142// ---------------------------------------------------------------------------
143
144// Every caller who was passing a `QueryBuilderConfig` or a
145// `Partial<QueryBuilderConfig>` keeps compiling — that is what makes widening
146// `setConfig()` a non-breaking change.
147export const fromResolved: QueryBuilderOptions = {} as QueryBuilderConfig
148export const fromPartial: QueryBuilderOptions = {} as Partial<QueryBuilderConfig>
149
150// ...and not the reverse, which is what preserves the internal guarantee that
151// every field of the resolved config is present.
152// @ts-expect-error - QueryBuilderOptions cannot stand in for the resolved config
153export const backToResolved: QueryBuilderConfig = {} as QueryBuilderOptions
packages/bun-query-builder/src/client.tsmodified+12-5
Changes to packages/bun-query-builder/src/client.ts
@@ -4,7 +4,8 @@
44import type { SchemaMeta } from './meta'
55import type { ResolvedPivot } from './pivot'
66import type { DatabaseSchema } from './schema'
7import { config, getPlaceholder, getPlaceholders, isMysqlLike } from './config'
7import type { QueryBuilderOptions } from './types'
8import { config, getPlaceholder, getPlaceholders, isMysqlLike, setConfig } from './config'
89import type { DriverConnection } from './db'
910import { bunSql, getOrCreateBunSql, resetConnection } from './db'
1011import { resolvePivot } from './pivot'
@@ -5968,10 +5969,16 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti
59685969 }
59695970
59705971 return {
5971 // Create a builder with per-instance option overrides
5972 configure(opts: Partial<typeof config>) {
5973 // This keeps types simple; for now, users can set global config via import
5974 Object.assign(config, opts)
5972 // Create a builder with per-instance option overrides.
5973 //
5974 // There is no per-instance config yet: this writes the same process-wide
5975 // singleton `setConfig()` does (see the warning on `setConfig`), so it goes
5976 // through the same merge rather than a bare `Object.assign`. That assign
5977 // was the last write path that could replace a whole nested section —
5978 // `db.configure({ debug: { captureText: false } })` used to drop every
5979 // other key of `debug`.
5980 configure(opts: QueryBuilderOptions) {
5981 setConfig(opts)
59755982 return this as any
59765983 },
59775984 /** Escape/validate identifier names (best-effort) */
packages/bun-query-builder/src/config.tsmodified+214-36
Changes to packages/bun-query-builder/src/config.ts
@@ -1,4 +1,4 @@
1import type { QueryBuilderConfig, SupportedDialect } from './types'
1import type { QueryBuilderConfig, QueryBuilderOptions, SupportedDialect } from './types'
22import process from 'node:process'
33import { loadConfig } from 'bunfig'
44
@@ -78,6 +78,113 @@ export const defaultConfig: QueryBuilderConfig = {
7878 },
7979}
8080
81/**
82 * Keys a merge must never write.
83 *
84 * `Object.assign` and plain property assignment both go through [[Set]], so
85 * writing a key literally named `__proto__` changes the target's prototype
86 * instead of adding a property — and `config` lives on a `Symbol.for` key
87 * shared by every copy of this module AND every version of this package in the
88 * process (see the singleton comment below), so one polluted merge poisons all
89 * of them. The realistic vector is not a `.ts` config file, which already
90 * executes arbitrary code, but bunfig's `package.json` config section, where
91 * `JSON.parse` turns `"__proto__"` into an ordinary own data property that
92 * then flows through `getConfig()` into here.
93 */
94const FORBIDDEN_MERGE_KEYS = new Set(['__proto__', 'constructor', 'prototype'])
95
96/**
97 * Whether a value is a plain object the merge may recurse into.
98 *
99 * Deliberately narrow. `typeof null === 'object'` is the classic deep-merge
100 * crash, and a class instance, `Date`, `Map` or `Set` would be silently
101 * dismantled into a bag of its own enumerable properties. Anything that is not
102 * `{}`-literal-shaped is a LEAF and gets assigned wholesale.
103 */
104function isPlainObject(value: unknown): value is Record<string, unknown> {
105 if (value === null || typeof value !== 'object' || Array.isArray(value))
106 return false
107 const proto = Object.getPrototypeOf(value)
108 return proto === Object.prototype || proto === null
109}
110
111/**
112 * Recursively merge `source` over `target`, returning a NEW object. Neither
113 * argument is mutated.
114 *
115 * The semantics below are load-bearing — each is pinned by a test or by a
116 * documented promise, so do not "simplify" them:
117 *
118 * - ARRAYS REPLACE. They do not concatenate or union. `SqliteConfig.pragmas`
119 * says so in its own doc comment, and `sqlite-bootstrap-pragmas.test.ts`
120 * asserts it by checking that `foreign_keys` is still 0 after an override
121 * that omits it — concatenation would flip that to 1.
122 * `transactionDefaults.sqlStates` is the same shape: a user narrowing
123 * retries to `['40002']` must stop retrying on `40001`, not keep it.
124 *
125 * - FUNCTIONS REPLACE. `isPlainObject` rejects them, so `QueryHooks` members
126 * and `BrowserConfig.getToken` / `transformResponse` stay opaque leaves. We
127 * never recurse into a function's own enumerable properties.
128 *
129 * - AN EXPLICIT `undefined` ASSIGNS; AN ABSENT KEY DOES NOT. The rule is
130 * PRESENCE, not value — `Object.keys(source)` contains a key written as
131 * `undefined` and does not contain one that was never written. Not a style
132 * choice: `resolve-dialect.test.ts` calls `setConfig({ dialect: undefined })`
133 * and expects `resolveDialect()` to fall back to `'postgres'`, and
134 * `sqlite-bootstrap-pragmas.test.ts` uses `sqlite: undefined` as the restore
135 * that un-does a pragma override for the rest of a shared-process run.
136 * Skipping `undefined` breaks both, and now that `hooks` deep-merges, an
137 * explicit `undefined` is also the only way to REMOVE a hook.
138 *
139 * - `null` ASSIGNS and is never recursed into. No field in
140 * `QueryBuilderConfig` is nullable, so a `null` arriving here is a caller
141 * mistake; assigning it surfaces that at the read site instead of hiding it.
142 *
143 * Arrays are copied by REFERENCE, being leaves — `config.transactionDefaults
144 * .sqlStates` is the same array as `defaultConfig`'s until something replaces
145 * it. Nothing in `src/` mutates a config array in place (the only read is an
146 * `.includes`), and cloning on every merge is not worth paying for that.
147 *
148 * `seen` is the chain of source objects currently being merged, so a source
149 * that points back at one of its own ancestors is treated as a leaf instead of
150 * recursed into. An embedding framework's config slice — the case this type
151 * exists to serve — routinely carries a parent back-pointer, and it typechecks
152 * fine because an optional field of an interface can be that interface. Without
153 * the guard that input spins for seconds, allocates gigabytes and dies with a
154 * stack overflow. It is a DEPTH chain, pushed and popped around the recursion,
155 * not a global set: the same object legitimately appearing under two different
156 * keys must merge both times, and a global set would silently drop the second.
157 */
158function mergeObject(
159 target: Record<string, unknown>,
160 source: Record<string, unknown>,
161 seen: Set<object> = new Set(),
162): Record<string, unknown> {
163 const out: Record<string, unknown> = { ...target }
164 seen.add(source)
165
166 for (const key of Object.keys(source)) {
167 if (FORBIDDEN_MERGE_KEYS.has(key))
168 continue
169
170 const next = source[key]
171 if (isPlainObject(next) && !seen.has(next)) {
172 const prev = out[key]
173 // When `prev` is not itself a mergeable object there is nothing to merge
174 // into, but we still recurse rather than storing the caller's literal by
175 // reference — otherwise a caller who mutates their own object later would
176 // be reaching into the live config.
177 out[key] = mergeObject(isPlainObject(prev) ? prev : {}, next, seen)
178 }
179 else {
180 out[key] = next
181 }
182 }
183
184 seen.delete(source)
185 return out
186}
187
81188// The single, process-wide config object — stored on a `globalThis` symbol so
82189// that EVERY copy of this module shares one object, even if Bun's bundler
83190// inlines `config.ts` more than once. Previously this was an `export let` that
@@ -94,9 +201,15 @@ export const defaultConfig: QueryBuilderConfig = {
94201// is also shared across package versions in one process — that's the intended
95202// hardening; pin a versioned key if you ever need per-version isolation.
96203// - Synchronous + no top-level await here, so `bun --compile` is unaffected.
204// - Seeded through `mergeObject`, not `{ ...defaultConfig }`. A shallow spread
205// left `config.debug === defaultConfig.debug`, so the direct writes this
206// package makes to the live config — `config.debug.captureText = true` in
207// `actions/sql.ts` and `computeSqlText()` — were reaching into the exported
208// DEFAULTS and permanently changing them. `mergeObject` clones every plain
209// object node, which closes that.
97210const CONFIG_SINGLETON_KEY = Symbol.for('bun-query-builder.config')
98211export const config: QueryBuilderConfig
99 = ((globalThis as any)[CONFIG_SINGLETON_KEY] ??= { ...defaultConfig })
212 = ((globalThis as any)[CONFIG_SINGLETON_KEY] ??= mergeObject({}, defaultConfig as unknown as Record<string, unknown>) as unknown as QueryBuilderConfig)
100213
101214/**
102215 * Config-precedence bookkeeping, on `globalThis` for the same reason `config`
@@ -110,6 +223,18 @@ export const config: QueryBuilderConfig
110223 * - `explicit`: top-level keys passed to `setConfig()`.
111224 * - `fileLoaded`: whether the config file has been read, so it is applied
112225 * once per process rather than once per inlined copy.
226 *
227 * The veto is deliberately WHOLE-SECTION, not per-leaf, even though
228 * `QueryBuilderOptions` now makes `setConfig({ database: { host } })` the
229 * normal idiom. Per-leaf looks more precise and is wrong: the fields of a
230 * section are not always independent settings. `database.url` and the discrete
231 * `database.host`/`port`/`username`/`password` are two mutually exclusive
232 * SPELLINGS of one setting — `createConnectionString()` returns `url` verbatim
233 * and never reads the others — so letting a config file's `url` survive
234 * because the embedder "only named host" hands the app a connection to a
235 * different database than the one it asked for, silently. Vetoing the section
236 * an embedder has spoken for keeps the two spellings from being mixed across
237 * sources.
113238 */
114239const CONFIG_STATE_KEY = Symbol.for('bun-query-builder.config-state')
115240interface ConfigState { explicit: Set<string>, fileLoaded: boolean }
@@ -199,6 +324,8 @@ export function getPlaceholders(count: number, startIndex = 1): string {
199324 *
200325 * So the loaded object is applied with those keys filtered out — an explicit API
201326 * call is a stronger signal than a file that may not know the embedder exists.
327 * The filter is per top-level section; see `configState` for why per-leaf is
328 * the wrong granularity.
202329 *
203330 * Note the load MUST pass `defaultConfig`: bunfig derives the environment
204331 * variable names it recognises (`QUERY_BUILDER_*`) from the shape of the
@@ -214,7 +341,7 @@ export async function getConfig(): Promise<QueryBuilderConfig> {
214341 defaultConfig,
215342 })
216343
217 const applicable: Partial<QueryBuilderConfig> = {}
344 const applicable: QueryBuilderOptions = {}
218345 for (const [key, value] of Object.entries(loaded ?? {})) {
219346 if (!configState.explicit.has(key))
220347 (applicable as Record<string, unknown>)[key] = value
@@ -264,41 +391,58 @@ const _warnedDialectConflicts = new Set<string>()
264391/**
265392 * Merge a partial config into the process-wide singleton.
266393 *
267 * Shared by `setConfig()` (explicit, marks keys) and `getConfig()` (from a
268 * file, does not) so the two paths can never drift on how nested objects merge.
394 * Shared by `setConfig()` (explicit, records precedence) and `getConfig()`
395 * (from a file, does not) so the two paths can never drift on how nested
396 * objects merge.
397 *
398 * This used to be `Object.assign(config, userConfig)` plus five hand-written
399 * nested spreads — `database`, `timestamps`, `pagination`, `softDeletes`,
400 * `vitess`. The other nine object-valued sections (`aliasing`, `relations`,
401 * `transactionDefaults`, `sql`, `sqlite`, `features`, `debug`, `hooks`,
402 * `browser`) were REPLACED wholesale, as were `database.pool` and
403 * `transactionDefaults.backoff` one level further down. That was survivable
404 * only because the parameter was `Partial<QueryBuilderConfig>`, which forced a
405 * caller who wanted to change one nested field to hand over a complete
406 * sub-object: the accident of an over-strict type was standing in for a merge
407 * that did not exist. Now that the parameter is `QueryBuilderOptions` and
408 * `setConfig({ transactionDefaults: { retries: 5 } })` is the intended idiom,
409 * the merge has to be real — otherwise the type fix would have traded a
410 * compile error for `config.transactionDefaults.backoff` being `undefined` at
411 * runtime, which is strictly worse.
412 *
413 * Two mutation rules, both required:
414 *
415 * 1. THE TOP-LEVEL `config` OBJECT IDENTITY NEVER CHANGES. Never reassign
416 * `config`, never rebuild it. Bun's bundler splits a reassigned binding so
417 * that writers and readers land on different identifiers — Stacks +
418 * bun-query-builder hit exactly that, where `setConfig({dialect:'sqlite'})`
419 * looked like a no-op because consumers still saw the `postgres` default.
420 * `dist.config-singleton.test.ts` asserts the identity survives bundling.
421 * So the merged result is copied back onto `config` key by key.
422 *
423 * 2. NESTED NODES ARE REPLACED WITH FRESH OBJECTS, NOT MUTATED IN PLACE
424 * (`mergeObject` returns new objects at every level). Seven test files
425 * capture a nested node by reference and hand it back later to restore it
426 * — `database-name.test.ts` holds `config.database` and passes it to a
427 * later `setConfig` — and that idiom only works if the merge replaced the
428 * node rather than mutating the object they are still holding. In-place
429 * mutation would make every one of those restores a silent no-op.
269430 */
270function applyConfig(userConfig: Partial<QueryBuilderConfig>): void {
271 // NEVER reassign `config` here (i.e. `config = { ...defaultConfig }`).
272 // Reassigning an `export let` triggers Bun's bundler to split the
273 // binding: the write goes to one identifier and every reader (e.g.
274 // `getBunSql`, `getPlaceholder`) keeps reading the original. Stacks +
275 // bun-query-builder hit this exact bug — `setConfig({dialect:'sqlite'})`
276 // looked like a no-op because consumers still saw the `postgres`
277 // default. If `config` is somehow undefined at call time, that's a
278 // bundler-init failure we can't paper over here without recreating the
279 // split, so let it surface instead. The module-top `config` singleton
280 // is the single source of truth.
281 Object.assign(config, userConfig)
431function applyConfig(userConfig: QueryBuilderOptions): void {
432 const merged = mergeObject(
433 config as unknown as Record<string, unknown>,
434 userConfig as Record<string, unknown>,
435 )
282436
283 // Handle nested objects like database, timestamps, etc.
284 if (userConfig.database) {
285 config.database = { ...config.database, ...userConfig.database }
286 }
287 if (userConfig.timestamps) {
288 config.timestamps = { ...config.timestamps, ...userConfig.timestamps }
289 }
290 if (userConfig.pagination) {
291 config.pagination = { ...config.pagination, ...userConfig.pagination }
292 }
293 if (userConfig.softDeletes) {
294 config.softDeletes = { ...config.softDeletes, ...userConfig.softDeletes }
295 }
296 if (userConfig.vitess) {
297 config.vitess = { ...config.vitess, ...userConfig.vitess }
437 const target = config as unknown as Record<string, unknown>
438 for (const key of Object.keys(merged)) {
439 if (FORBIDDEN_MERGE_KEYS.has(key))
440 continue
441 target[key] = merged[key]
298442 }
299443}
300444
301export function setConfig(userConfig: Partial<QueryBuilderConfig>): void {
445export function setConfig(userConfig: QueryBuilderOptions): void {
302446 // Detect cross-instance dialect conflicts and warn once. The proper
303447 // fix is per-instance config (stacksjs/stacks#1862 #18); this guard
304448 // surfaces the symptom so callers can see the shared-state problem
@@ -308,10 +452,14 @@ export function setConfig(userConfig: Partial<QueryBuilderConfig>): void {
308452 const key = `${_lastConfiguredDialect}->${userConfig.dialect}`
309453 if (!_warnedDialectConflicts.has(key)) {
310454 _warnedDialectConflicts.add(key)
455 // Phrased without naming the entry point: `db.configure()` routes through
456 // here too, and telling that caller to go find a `setConfig` call they
457 // never wrote sends them hunting for a site that does not exist.
311458 console.warn(
312 `[query-builder] setConfig({ dialect: '${userConfig.dialect}' }) overrides a previous `
313 + `setConfig({ dialect: '${_lastConfiguredDialect}' }). `
314 + `Config is process-wide; in-flight queries from the previous configuration may break. `
459 `[query-builder] Configuring dialect '${userConfig.dialect}' overrides the previously `
460 + `configured dialect '${_lastConfiguredDialect}'. `
461 + `Config is process-wide (setConfig, db.configure and query-builder.config.ts all write it); `
462 + `in-flight queries from the previous configuration may break. `
315463 + `Run conflicting connections in separate processes — see stacksjs/stacks#1862 #18.`,
316464 )
317465 }
@@ -325,3 +473,33 @@ export function setConfig(userConfig: Partial<QueryBuilderConfig>): void {
325473
326474 applyConfig(userConfig)
327475}
476
477/**
478 * Identity helper for a typed `query-builder.config.ts`.
479 *
480 * It exists so that writing a config file requires no decision about which
481 * type to annotate with — which is what stacksjs/bun-query-builder#1062 was
482 * actually about. The only config type this package exported was
483 * `QueryBuilderConfig`, the RESOLVED shape with every field required, and the
484 * docs told people to import it, so downstream apps annotated their config
485 * file with a type demanding `migrationDir`, `snapshotDir`, `features`,
486 * `aliasing` and nine other sections they had no opinion about. Every field
487 * added here afterwards was a downstream build break.
488 *
489 * `defineConfig` takes `QueryBuilderOptions`, so nothing is ever required, and
490 * returns its argument unchanged — there is no runtime behavior here, only the
491 * type and the editor completion that comes with it.
492 *
493 * ```ts
494 * // query-builder.config.ts
495 * import { defineConfig } from 'bun-query-builder'
496 *
497 * export default defineConfig({
498 * dialect: 'postgres',
499 * database: { database: 'my_app' },
500 * })
501 * ```
502 */
503export function defineConfig(userConfig: QueryBuilderOptions): QueryBuilderOptions {
504 return userConfig
505}
packages/bun-query-builder/src/types.tsmodified+89-0
Changes to packages/bun-query-builder/src/types.ts
@@ -452,6 +452,95 @@ export interface QueryBuilderConfig {
452452 }
453453}
454454
455/**
456 * # `DeepPartial`
457 *
458 * Every property of `T`, recursively optional — the shape of configuration
459 * INPUT, as opposed to the resolved shape the library reads back out.
460 *
461 * `QueryBuilderConfig` describes the config AFTER `defaultConfig` has been
462 * merged in, so its fields are non-optional on purpose: `getPlaceholder()`,
463 * the dialect dispatch, `resolveDialect()` and the model layer all read
464 * `config.x.y` without guarding, and that is only sound because every key is
465 * guaranteed present. That guarantee is worth keeping.
466 *
467 * The problem was that consumers were handed the same interface to annotate
468 * their own `query-builder.config.ts` with, because it was the only one this
469 * package exported. In that role every non-optional field is a bug: adding
470 * `migrationDir: string` to the resolved config — and `snapshotDir` before it —
471 * broke `tsc --noEmit` in every downstream app until each of them restated
472 * `'database/migrations'`, a value this package already defaults in three
473 * places (`config.ts`, `workspace.ts:38`, `workspace.ts:39`). Nothing in a
474 * config file should ever be required. So consumers get `QueryBuilderOptions`
475 * and internal code keeps `QueryBuilderConfig`.
476 *
477 * The guard clauses are not decoration. A plain
478 * `{ [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K] }` gets
479 * each of these wrong, and the order matters:
480 *
481 * 1. FUNCTIONS FIRST. A function type extends `object`, so a naive recursion
482 * descends into it — but a mapped type copies properties, and a call
483 * signature is not a property. `keyof (() => void)` is `never`, so the
484 * hook collapses to an empty object type: no longer callable, and loose
485 * enough to accept `42`. It also erases type parameters, which would turn
486 * `BrowserConfig.transformResponse` (`<T>(response: any) => T`) into a
487 * shape with no signature at all. A hook or a token getter is a leaf.
488 *
489 * 2. ARRAYS SECOND, because the merge replaces them rather than merging them.
490 * A homomorphic map over `string[]` rewrites `TransactionDefaultsConfig`'s
491 * `sqlStates` as `(string | undefined)[]`, which no longer assigns back to
492 * `string[]`. Same for `SqliteConfig.pragmas`, whose own doc comment
493 * already promises that setting it REPLACES the default list. A
494 * half-supplied array is not a thing, so arrays are leaves — and they come
495 * back out `readonly`, so that a config file written the idiomatic
496 * `export default { … } as const` way still assigns. Nothing in `src/`
497 * mutates a config array, so asking callers for a mutable one bought
498 * nothing and rejected `as const`.
499 *
500 * 3. INDEX SIGNATURES THIRD, because there is nothing to make optional.
501 * `BrowserConfig.headers` is a `Record<string, string>` bag whose keys are
502 * all optional already, so recursing buys nothing and costs something: it
503 * rewrites the bag as `{ [x: string]: string | undefined }`, which then no
504 * longer assigns back OUT to a `Record<string, string>`, and which reads
505 * worse on hover. (At runtime a bag is indistinguishable from a config
506 * section, so `setConfig` does merge bags key by key — two calls each
507 * naming one header leave you with both.)
508 *
509 * String-literal unions (`SupportedDialect`, `relationColumnAliasFormat`, …)
510 * and primitives need no clause — they are not objects, so the final check
511 * passes them through and a typo in `dialect` is still an error.
512 *
513 * There is no `Date`, `RegExp`, `Map` or `Set` anywhere in
514 * `QueryBuilderConfig`. If one is ever added, give it a clause here too: a
515 * mapped type shreds its methods exactly like a function's.
516 */
517export type DeepPartial<T> = T extends (...args: any[]) => any // eslint-disable-line pickier/no-unused-vars -- `args` names a parameter in a function TYPE, not a binding
518 ? T
519 : T extends readonly (infer E)[]
520 ? readonly E[]
521 : string extends keyof T
522 ? T
523 : number extends keyof T
524 ? T
525 : T extends object
526 ? { [K in keyof T]?: DeepPartial<T[K]> }
527 : T
528
529/**
530 * # `QueryBuilderOptions`
531 *
532 * The shape a consumer writes: a `query-builder.config.ts`, an argument to
533 * `setConfig()` or `db.configure()`, an embedding framework's own config
534 * slice. Everything is optional at every depth — `defaultConfig` supplies the
535 * rest, and `setConfig()` deep-merges what you do supply, so naming one leaf
536 * of a section keeps the section's other defaults.
537 *
538 * Type your configuration against THIS, not `QueryBuilderConfig`. That one is
539 * the resolved result the library reads; every field added to it would
540 * otherwise become a compile error in your app.
541 */
542export type QueryBuilderOptions = DeepPartial<QueryBuilderConfig>
543
455544export interface CliOption {
456545 verbose: boolean
457546}
packages/bun-query-builder/test/config-merge.test.tsadded+223-0
Changes to packages/bun-query-builder/test/config-merge.test.ts
@@ -0,0 +1,223 @@
1import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
2import { buildDatabaseSchema, config, createQueryBuilder, defaultConfig, setConfig } from '../src'
3
4/**
5 * `setConfig()` deep-merges.
6 *
7 * Regression cover for stacksjs/bun-query-builder#1062. That issue was about
8 * types — `QueryBuilderConfig` forced apps to restate defaults the library
9 * already supplies — but the fix could not be type-only. `applyConfig()` did
10 * `Object.assign` plus five hand-written nested spreads, so only `database`,
11 * `timestamps`, `pagination`, `softDeletes` and `vitess` merged, and only one
12 * level deep. Everything else was replaced wholesale. That was survivable
13 * exactly because the over-strict type forced callers to hand over complete
14 * sub-objects: the bad type was standing in for the missing merge.
15 *
16 * Widening the parameter to `QueryBuilderOptions` without fixing the merge
17 * would have turned a compile error into a silent `undefined` at runtime —
18 * `setConfig({ transactionDefaults: { retries: 5 } })` typechecking and then
19 * dropping `sqlStates` and `backoff`. These tests are what stops that
20 * regression from shipping.
21 *
22 * `config` is a process-wide singleton shared with ~27 other test files, so
23 * every case snapshots and restores it.
24 */
25
26/** Deep-copy plain objects, leave every other value (arrays, functions) alone. */
27function clone<T>(value: T): T {
28 if (value === null || typeof value !== 'object' || Array.isArray(value))
29 return value
30 const out: Record<string, unknown> = {}
31 for (const key of Object.keys(value as Record<string, unknown>))
32 out[key] = clone((value as Record<string, unknown>)[key])
33 return out as T
34}
35
36let saved: Record<string, unknown>
37
38beforeEach(() => {
39 saved = clone(config as unknown as Record<string, unknown>)
40})
41
42afterEach(() => {
43 for (const key of Object.keys(config))
44 delete (config as Record<string, unknown>)[key]
45 Object.assign(config, saved)
46})
47
48describe('setConfig deep merge', () => {
49 it('merges a nested section instead of replacing it', () => {
50 setConfig({ transactionDefaults: { retries: 5 } })
51
52 expect(config.transactionDefaults.retries).toBe(5)
53 // The two that used to vanish. `sqlStates` undefined means every retry
54 // decision throws; `backoff` undefined means the retry delay math does.
55 expect(config.transactionDefaults.sqlStates).toEqual(['40001', '40P01'])
56 expect(config.transactionDefaults.backoff.baseMs).toBe(50)
57 })
58
59 it('merges two levels deep', () => {
60 setConfig({ transactionDefaults: { backoff: { jitter: false } } })
61
62 expect(config.transactionDefaults.backoff.jitter).toBe(false)
63 expect(config.transactionDefaults.backoff.baseMs).toBe(50)
64 expect(config.transactionDefaults.backoff.factor).toBe(2)
65 expect(config.transactionDefaults.retries).toBe(2)
66 })
67
68 it('merges database.pool across successive calls', () => {
69 const database = config.database.database
70
71 setConfig({ database: { pool: { max: 20 } } })
72 setConfig({ database: { pool: { idleTimeoutMs: 1000 } } })
73
74 // Broken even before the type change: `database` merged, but `database.pool`
75 // one level further down did not, so each call dropped the other's field.
76 expect(config.database.pool?.max).toBe(20)
77 expect(config.database.pool?.idleTimeoutMs).toBe(1000)
78 expect(config.database.database).toBe(database)
79 })
80
81 it('merges sibling sections independently', () => {
82 setConfig({ sql: { randomFunction: 'RAND()' } })
83
84 expect(config.sql.randomFunction).toBe('RAND()')
85 expect(config.sql.sharedLockSyntax).toBe('FOR SHARE')
86 expect(config.sql.jsonContainsMode).toBe('operator')
87 })
88
89 it('replaces arrays wholesale rather than concatenating them', () => {
90 setConfig({ transactionDefaults: { sqlStates: ['40002'] } })
91
92 // A user narrowing the retriable set must stop retrying on 40001.
93 expect(config.transactionDefaults.sqlStates).toEqual(['40002'])
94 })
95
96 it('replaces functions wholesale and never recurses into them', () => {
97 const first = (): void => {}
98 const second = (): void => {}
99
100 setConfig({ hooks: { onQueryStart: first } })
101 setConfig({ hooks: { onQueryStart: second } })
102
103 expect(config.hooks?.onQueryStart).toBe(second)
104 expect(typeof config.hooks?.onQueryStart).toBe('function')
105 })
106
107 it('accumulates sibling hooks instead of wiping them', () => {
108 const start = (): void => {}
109 const end = (): void => {}
110
111 setConfig({ hooks: { onQueryStart: start } })
112 setConfig({ hooks: { onQueryEnd: end } })
113
114 expect(config.hooks?.onQueryStart).toBe(start)
115 expect(config.hooks?.onQueryEnd).toBe(end)
116 })
117
118 it('assigns an explicit undefined but leaves absent keys alone', () => {
119 // Presence, not value: this is how `resolve-dialect.test.ts` clears the
120 // dialect and how `sqlite-bootstrap-pragmas.test.ts` un-does an override.
121 setConfig({ dialect: undefined })
122 expect(config.dialect).toBeUndefined()
123
124 setConfig({ dialect: 'sqlite' })
125 setConfig({ verbose: false })
126 expect(config.dialect).toBe('sqlite')
127 expect(config.snapshotDir).toBe('.qb')
128 })
129
130 it('assigns null without recursing into it', () => {
131 expect(() => setConfig({ sqlite: null as any })).not.toThrow()
132 expect(config.sqlite).toBeNull()
133 })
134
135 it('treats a self-referential config as a leaf instead of recursing forever', () => {
136 // An embedding framework's config slice routinely carries a parent
137 // back-pointer, and it typechecks — an optional field of an interface can
138 // be that interface. Without a cycle guard this spins for seconds,
139 // allocates gigabytes and dies with a stack overflow.
140 const cyclic: any = { database: { host: 'cyclic-host' } }
141 cyclic.database.root = cyclic
142
143 const started = Bun.nanoseconds()
144 expect(() => setConfig(cyclic)).not.toThrow()
145 const elapsedMs = (Bun.nanoseconds() - started) / 1e6
146
147 expect(elapsedMs).toBeLessThan(500)
148 expect(config.database.host).toBe('cyclic-host')
149 })
150
151 it('merges the same object appearing under two keys, both times', () => {
152 // The cycle guard is a DEPTH chain, popped on the way out — a global seen-set
153 // would treat the second occurrence as already-visited and silently drop it.
154 const shared = { captureText: false }
155 setConfig({ debug: shared, hooks: shared as any })
156
157 expect(config.debug?.captureText).toBe(false)
158 expect((config.hooks as any).captureText).toBe(false)
159 })
160})
161
162describe('setConfig mutation discipline', () => {
163 it('preserves the top-level config object identity', () => {
164 // Reassigning `config` splits the binding under Bun's bundler and every
165 // reader keeps seeing the old object — see the comment on `applyConfig`.
166 const ref = config
167
168 setConfig({ verbose: false })
169 setConfig({ transactionDefaults: { retries: 4 } })
170 setConfig({ database: { host: 'elsewhere' } })
171
172 expect(config).toBe(ref)
173 })
174
175 it('replaces nested nodes rather than mutating them in place', () => {
176 // Seven test files capture a nested node and hand it back later to restore
177 // it. That only works if the merge left the object they are holding alone.
178 const previous = config.database
179 const host = previous.host
180
181 setConfig({ database: { host: 'other' } })
182
183 expect(config.database).not.toBe(previous)
184 expect(previous.host).toBe(host)
185 expect(config.database.host).toBe('other')
186 })
187
188 it('does not mutate defaultConfig', () => {
189 // `config` used to be a SHALLOW spread of `defaultConfig`, so the two
190 // shared every nested node and a write to one changed the other.
191 expect(config.sql).not.toBe(defaultConfig.sql)
192
193 setConfig({ sql: { randomFunction: 'RAND()' } })
194
195 expect(defaultConfig.sql.randomFunction).toBe('RANDOM()')
196 })
197
198 it('ignores __proto__ arriving through a merge', () => {
199 // The vector is bunfig's package.json config section, where JSON.parse
200 // turns "__proto__" into an ordinary own property.
201 setConfig(JSON.parse('{"__proto__":{"polluted":true}}'))
202 expect(({} as any).polluted).toBeUndefined()
203 expect((config as any).polluted).toBeUndefined()
204
205 setConfig(JSON.parse('{"database":{"__proto__":{"alsoPolluted":true}}}'))
206 expect(({} as any).alsoPolluted).toBeUndefined()
207 expect((config.database as any).alsoPolluted).toBeUndefined()
208 })
209})
210
211describe('db.configure', () => {
212 it('deep-merges like setConfig instead of replacing a section', () => {
213 const schema = buildDatabaseSchema({
214 User: { name: 'User', table: 'users', primaryKey: 'id', attributes: { id: { validation: { rule: {} } } } },
215 } as any)
216 const db = createQueryBuilder<typeof schema>({ schema })
217
218 setConfig({ debug: { captureText: true } })
219 db.configure({ debug: { captureText: false } })
220
221 expect(config.debug?.captureText).toBe(false)
222 })
223})
packages/bun-query-builder/test/config-precedence.test.tsmodified+57-0
Changes to packages/bun-query-builder/test/config-precedence.test.ts
@@ -25,6 +25,9 @@ interface ProbeResult {
2525 snapshotDir?: string
2626 dialect?: string
2727 verbose?: boolean
28 dbHost?: string
29 dbPort?: number
30 dbUrl?: string
2831}
2932
3033/**
@@ -46,6 +49,9 @@ function probe(configFile: string, setup: string): ProbeResult {
4649 snapshotDir: config.snapshotDir,
4750 dialect: config.dialect,
4851 verbose: config.verbose,
52 dbHost: config.database?.host,
53 dbPort: config.database?.port,
54 dbUrl: config.database?.url,
4955 }))
5056 `
5157
@@ -116,4 +122,55 @@ describe('config precedence', () => {
116122
117123 expect(result.snapshotDir).toBe('.qb')
118124 })
125
126 /**
127 * The veto is per SECTION, not per leaf — naming one field of `database`
128 * discards the file's whole `database` section.
129 *
130 * That looks coarse, and a per-leaf veto was tried. It is wrong, because the
131 * fields of a section are not always independent settings: `database.url` and
132 * the discrete `host`/`port`/`username`/`password` are two mutually exclusive
133 * SPELLINGS of one setting, and `createConnectionString()` returns `url`
134 * verbatim without reading any of the others. Under a per-leaf veto, an
135 * embedder passing discrete credentials never names `url`, so a stale `url`
136 * in the config file survives the merge and silently wins — the app connects
137 * to a different database than the one its own code asked for.
138 *
139 * So an embedder who speaks for a section owns that whole section.
140 */
141 it('discards the whole section from the file when setConfig named any of it', () => {
142 const result = probe(
143 `export default { database: { port: 6543 } }`,
144 `setConfig({ database: { host: 'from-embedder' } })`,
145 )
146
147 expect(result.dbHost).toBe('from-embedder')
148 expect(result.dbPort).toBe(5432)
149 })
150
151 it('keeps a config-file section that setConfig never mentioned', () => {
152 const result = probe(
153 `export default { database: { host: 'from-file', port: 6543 } }`,
154 `setConfig({ snapshotDir: 'from-embedder' })`,
155 )
156
157 expect(result.dbHost).toBe('from-file')
158 expect(result.dbPort).toBe(6543)
159 expect(result.snapshotDir).toBe('from-embedder')
160 })
161
162 /**
163 * The wrong-database scenario itself, pinned directly: a config file left
164 * over with a `url`, and application code configuring discrete credentials.
165 */
166 it('does not let a stale url in the config file override configured credentials', () => {
167 const result = probe(
168 `export default { database: { url: 'postgres://user:pw@stale-host:5432/stale_db' } }`,
169 `setConfig({ database: { host: 'real-host', port: 6543, database: 'real_db' } })`,
170 )
171
172 expect(result.dbUrl).toBeUndefined()
173 expect(result.dbHost).toBe('real-host')
174 expect(result.dbPort).toBe(6543)
175 })
119176})