also looking at this
fix(types): config options should not require what the library already defaults
#1064
12 files
+885
-117
| @@ -81,6 +81,27 @@ const q = db | ||
| 81 | 81 | const rows = await q.execute() |
| 82 | 82 | ``` |
| 83 | 83 | |
| 84 | ### Configuration | |
| 85 | ||
| 86 | Drop a `query-builder.config.ts` in your project root. Every field is optional at | |
| 87 | every depth — supply only what you want to change, and the library defaults the rest: | |
| 88 | ||
| 89 | ```ts | |
| 90 | import { defineConfig } from 'bun-query-builder' | |
| 91 | ||
| 92 | export 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(). | |
| 100 | import { getConfig } from 'bun-query-builder' | |
| 101 | ||
| 102 | await getConfig() | |
| 103 | ``` | |
| 104 | ||
| 84 | 105 | ### Aggregations |
| 85 | 106 | |
| 86 | 107 | ```ts |
| @@ -7,7 +7,7 @@ This page summarizes method signatures and brief descriptions. See feature pages | ||
| 7 | 7 | ## Imports |
| 8 | 8 | |
| 9 | 9 | ```ts |
| 10 | import type { CursorPaginationResult, DatabaseSchema, PaginationResult, QueryBuilderConfig, SchemaMeta, SortDirection, TransactionOptions, WhereExpression, WhereOperator } from 'bun-query-builder' | |
| 10 | import type { CursorPaginationResult, DatabaseSchema, PaginationResult, QueryBuilderOptions, SchemaMeta, SortDirection, TransactionOptions, WhereExpression, WhereOperator } from 'bun-query-builder' | |
| 11 | 11 | import { |
| 12 | 12 | // Schema and Model Definition |
| 13 | 13 | buildDatabaseSchema, |
| @@ -41,10 +41,26 @@ const db = createQueryBuilder<typeof schema>({ schema, meta }) | ||
| 41 | 41 | |
| 42 | 42 | ## Configuration |
| 43 | 43 | |
| 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 | |
| 46 | 49 | |
| 47 | Key sections: `dialect`, `timestamps`, `pagination`, `aliasing`, `relations`, `transactionDefaults`, `sql`, `features`, `debug`. | |
| 50 | Two types, two jobs. **Write** `QueryBuilderOptions`: every field optional at every | |
| 51 | depth, so you supply only what you want to change. **Read** `QueryBuilderConfig`: | |
| 52 | your options merged over the defaults, every field present. Annotating your own | |
| 53 | config with the resolved type is what made every field added here a compile error | |
| 54 | downstream ([#1062](https://github.com/stacksjs/bun-query-builder/issues/1062)). | |
| 55 | ||
| 56 | Key sections: `dialect`, `database`, `snapshotDir`, `migrationDir`, `timestamps`, `pagination`, `aliasing`, `relations`, `transactionDefaults`, `sql`, `features`, `softDeletes`, `hooks`, `debug`. | |
| 57 | ||
| 58 | Naming one leaf keeps the rest of its section: | |
| 59 | ||
| 60 | ```ts | |
| 61 | setConfig({ transactionDefaults: { retries: 5 } }) | |
| 62 | // retries is 5; sqlStates and backoff keep their defaults | |
| 63 | ``` | |
| 48 | 64 | |
| 49 | 65 | ## Schema & Models |
| 50 | 66 | |
| @@ -276,7 +292,9 @@ await db | ||
| 276 | 292 | |
| 277 | 293 | - WhereOperator: '=', '!=', '<', '>', '<=', '>=', 'like', 'in', 'not in', 'is', 'is not' |
| 278 | 294 | - 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<T>: the utility behind `QueryBuilderOptions` | |
| 280 | 298 | - DatabaseSchema<ModelRecord> |
| 281 | 299 | |
| 282 | 300 | --- |
| @@ -472,7 +472,9 @@ function trackConfigurationChange(key: string, oldValue: any, newValue: any) { | ||
| 472 | 472 | |
| 473 | 473 | ### How do per-instance overrides work |
| 474 | 474 | |
| 475 | Call `db.configure(partialConfig)` to shallow-merge selected properties for that builder instance. | |
| 475 | Call `db.configure(options)` to deep-merge selected properties — naming one leaf of a | |
| 476 | section keeps that section's other defaults. Note there is no per-instance config yet: | |
| 477 | this writes the same process-wide config `setConfig()` does. | |
| 476 | 478 | |
| 477 | 479 | ### Can I change config at runtime |
| 478 | 480 | |
| @@ -1,93 +1,82 @@ | ||
| 1 | 1 | # Configuration |
| 2 | 2 | |
| 3 | The 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. | |
| 3 | The 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 | |
| 6 | leave out keeps the library default, so fields added in future releases never become | |
| 7 | your problem. | |
| 4 | 8 | |
| 5 | 9 | ## Quick Start |
| 6 | 10 | |
| 11 | ### A config file | |
| 12 | ||
| 13 | `query-builder.config.ts` in your project root, picked up by `getConfig()`: | |
| 14 | ||
| 7 | 15 | ```ts |
| 8 | import { config } from 'bun-query-builder' | |
| 16 | import { defineConfig } from 'bun-query-builder' | |
| 9 | 17 | |
| 10 | // Basic PostgreSQL configuration | |
| 11 | config.dialect = 'postgres' | |
| 12 | config.sql.randomFunction = 'RANDOM()' | |
| 13 | config.sql.sharedLockSyntax = 'FOR SHARE' | |
| 14 | config.sql.jsonContainsMode = 'operator' | |
| 15 | config.aliasing.relationColumnAliasFormat = 'table_column' | |
| 16 | config.transactionDefaults.retries = 2 | |
| 18 | export default defineConfig({ | |
| 19 | dialect: 'postgres', | |
| 20 | database: { database: 'my_app' }, | |
| 21 | }) | |
| 17 | 22 | ``` |
| 18 | 23 | |
| 19 | ## Configuration Interface | |
| 20 | ||
| 21 | 24 | ```ts |
| 22 | export 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: | |
| 26 | import { getConfig } from 'bun-query-builder' | |
| 33 | 27 | |
| 34 | // Pagination defaults | |
| 35 | pagination: { | |
| 36 | defaultPerPage: number | |
| 37 | cursorColumn: string | |
| 38 | } | |
| 28 | await getConfig() // applies the config file + QUERY_BUILDER_* env vars | |
| 29 | ``` | |
| 39 | 30 | |
| 40 | // Column aliasing for relations | |
| 41 | aliasing: { | |
| 42 | relationColumnAliasFormat: 'table_column' | 'table.dot.column' | 'camelCase' | |
| 43 | } | |
| 31 | Loading is explicit and async on purpose: the builder otherwise runs off the | |
| 32 | synchronous config singleton, so an early query can never race a background load. | |
| 44 | 33 | |
| 45 | // Relationship configuration | |
| 46 | relations: { | |
| 47 | foreignKeyFormat: 'singularParent_id' | 'parentId' | |
| 48 | singularizeStrategy?: 'stripTrailingS' | 'none' | |
| 49 | } | |
| 34 | ### From application code | |
| 50 | 35 | |
| 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: | |
| 63 | 37 | |
| 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 | |
| 39 | import { setConfig } from 'bun-query-builder' | |
| 70 | 40 | |
| 71 | // Feature toggles | |
| 72 | features: { | |
| 73 | distinctOn: boolean | |
| 74 | } | |
| 41 | setConfig({ dialect: 'sqlite', pagination: { defaultPerPage: 50 } }) | |
| 75 | 42 | |
| 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. | |
| 45 | setConfig({ transactionDefaults: { retries: 5 } }) | |
| 46 | ``` | |
| 80 | 47 | |
| 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 | } | |
| 48 | Precedence is `defaults < config file < setConfig()`. | |
| 49 | ||
| 50 | ### Mutating the live config | |
| 51 | ||
| 52 | ```ts | |
| 53 | import { config } from 'bun-query-builder' | |
| 54 | ||
| 55 | config.dialect = 'postgres' | |
| 56 | config.sql.randomFunction = 'RANDOM()' | |
| 57 | config.aliasing.relationColumnAliasFormat = 'table_column' | |
| 89 | 58 | ``` |
| 90 | 59 | |
| 60 | Note that assigning a whole section (`config.sql = { … }`) replaces it rather than | |
| 61 | merging — 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 | ||
| 70 | Annotate your own configuration with `QueryBuilderOptions` (or skip the decision | |
| 71 | entirely and use `defineConfig`). Declaring it against the resolved | |
| 72 | `QueryBuilderConfig` is what made every field added to this package a compile error | |
| 73 | in downstream apps — see | |
| 74 | [#1062](https://github.com/stacksjs/bun-query-builder/issues/1062). | |
| 75 | ||
| 76 | The authoritative field list ships in the package's type declarations, so your editor | |
| 77 | completes it inline; this page deliberately no longer duplicates the interface, since | |
| 78 | a second hand-maintained copy of the config shape drifts exactly the way the types did. | |
| 79 | ||
| 91 | 80 | ## Environment-Specific Configurations |
| 92 | 81 | |
| 93 | 82 | ### Production PostgreSQL Setup |
| @@ -45,6 +45,30 @@ await QueryBuilder | ||
| 45 | 45 | .execute() |
| 46 | 46 | ``` |
| 47 | 47 | |
| 48 | ## Configuration | |
| 49 | ||
| 50 | Drop a `query-builder.config.ts` in your project root. Every field is optional at | |
| 51 | every depth — supply only what you want to change, and the library defaults the rest: | |
| 52 | ||
| 53 | ```typescript | |
| 54 | import { defineConfig } from 'bun-query-builder' | |
| 55 | ||
| 56 | export 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(). | |
| 64 | import { getConfig } from 'bun-query-builder' | |
| 65 | ||
| 66 | await getConfig() | |
| 67 | ``` | |
| 68 | ||
| 69 | Type your own configuration against `QueryBuilderOptions` (what you write, everything | |
| 70 | optional), not `QueryBuilderConfig` (what the library reads back, everything present). | |
| 71 | ||
| 48 | 72 | ## Features |
| 49 | 73 | |
| 50 | 74 | - **Typed from Models** - Infer tables, columns, and primary keys from your model files |
| @@ -4,6 +4,12 @@ import { join, resolve } from 'node:path' | ||
| 4 | 4 | import process from 'node:process' |
| 5 | 5 | import { dts } from 'bun-plugin-dtsx' |
| 6 | 6 | |
| 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 | |
| 7 | 13 | const result = await Bun.build({ |
| 8 | 14 | entrypoints: ['src/index.ts', 'src/browser.ts', 'src/dynamodb/index.ts', 'bin/cli.ts'], |
| 9 | 15 | outdir: './dist', |
| @@ -70,4 +76,5 @@ async function assertCompilable(entry: string): Promise<void> { | ||
| 70 | 76 | process.exit(1) |
| 71 | 77 | } |
| 72 | 78 | |
| 79 | // eslint-disable-next-line ts/no-top-level-await -- build script, not shipped; see the note above | |
| 73 | 80 | await assertCompilable('./dist/src/index.js') |
| @@ -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 | ||
| 19 | import type { QueryBuilderConfig, QueryBuilderOptions } from '../types' | |
| 20 | import { defineConfig, setConfig } from '../config' | |
| 21 | ||
| 22 | // --------------------------------------------------------------------------- | |
| 23 | // 1. Nothing is required, at any depth | |
| 24 | // --------------------------------------------------------------------------- | |
| 25 | ||
| 26 | // The empty config: the library supplies everything. | |
| 27 | export const empty: QueryBuilderOptions = {} | |
| 28 | ||
| 29 | // One top-level key. `migrationDir` is the field whose arrival broke #1062. | |
| 30 | export const oneKey: QueryBuilderOptions = { dialect: 'sqlite' } | |
| 31 | export 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`. | |
| 35 | export const oneLeaf: QueryBuilderOptions = { timestamps: { createdAt: 'inserted_at' } } | |
| 36 | export 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. | |
| 43 | export const softOnly: QueryBuilderOptions = { softDeletes: { enabled: true } } | |
| 44 | export const emptyVitess: QueryBuilderOptions = { vitess: {} } | |
| 45 | export const emptyDebug: QueryBuilderOptions = { debug: {} } | |
| 46 | export const browserLeaf: QueryBuilderOptions = { browser: { timeout: 1000 } } | |
| 47 | ||
| 48 | // Two levels down. | |
| 49 | export const deepLeaf: QueryBuilderOptions = { transactionDefaults: { backoff: { jitter: false } } } | |
| 50 | export const poolLeaf: QueryBuilderOptions = { database: { pool: { max: 20 } } } | |
| 51 | ||
| 52 | // The call sites take it too. | |
| 53 | setConfig({ pagination: { defaultPerPage: 50 } }) | |
| 54 | export 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. | |
| 63 | export const hooked: QueryBuilderOptions = { | |
| 64 | hooks: { | |
| 65 | onQueryStart: (event) => { void event.sql }, | |
| 66 | onQueryEnd: (event) => { void event.durationMs }, | |
| 67 | }, | |
| 68 | } | |
| 69 | hooked.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. | |
| 73 | type TransformResponse = NonNullable<NonNullable<QueryBuilderOptions['browser']>['transformResponse']> | |
| 74 | export const transform: TransformResponse = (response: any) => response | |
| 75 | export 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. | |
| 85 | export const states: QueryBuilderOptions = { transactionDefaults: { sqlStates: ['40001'] } } | |
| 86 | export const statesOut: readonly string[] = states.transactionDefaults!.sqlStates! | |
| 87 | export const pragmas: QueryBuilderOptions = { sqlite: { pragmas: ['PRAGMA foreign_keys = ON'] } } | |
| 88 | export 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. | |
| 92 | export const asConst: QueryBuilderOptions = { | |
| 93 | dialect: 'sqlite', | |
| 94 | transactionDefaults: { sqlStates: ['40001', '40P01'] }, | |
| 95 | } as const | |
| 96 | ||
| 97 | const sharedStates: readonly string[] = ['40001'] | |
| 98 | export const hoisted: QueryBuilderOptions = { transactionDefaults: { sqlStates: sharedStates } } | |
| 99 | ||
| 100 | const mutableStates: string[] = ['40001'] | |
| 101 | export const fromMutable: QueryBuilderOptions = { transactionDefaults: { sqlStates: mutableStates } } | |
| 102 | ||
| 103 | // --------------------------------------------------------------------------- | |
| 104 | // 4. Record bags stay intact | |
| 105 | // --------------------------------------------------------------------------- | |
| 106 | ||
| 107 | const headers: Record<string, string> = { authorization: 'Bearer x' } | |
| 108 | export 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. | |
| 113 | export 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 | |
| 120 | export const badDialect: QueryBuilderOptions = { dialect: 'oracle' } | |
| 121 | ||
| 122 | // @ts-expect-error - createdAt is a column name, not a number | |
| 123 | export const badLeafType: QueryBuilderOptions = { timestamps: { createdAt: 42 } } | |
| 124 | ||
| 125 | // @ts-expect-error - typo in a nested key is still an excess property | |
| 126 | export const typo: QueryBuilderOptions = { timestamps: { craetedAt: 'created_at' } } | |
| 127 | ||
| 128 | // @ts-expect-error - defaultPerPage is a number | |
| 129 | export const badPagination: QueryBuilderOptions = { pagination: { defaultPerPage: 'lots' } } | |
| 130 | ||
| 131 | // @ts-expect-error - foreignKeyFormat is a closed union | |
| 132 | export const badRelations: QueryBuilderOptions = { relations: { foreignKeyFormat: 'nope' } } | |
| 133 | ||
| 134 | // @ts-expect-error - a hook must be callable, not a number | |
| 135 | export const badHook: QueryBuilderOptions = { hooks: { onQueryStart: 42 } } | |
| 136 | ||
| 137 | // @ts-expect-error - headers values are strings | |
| 138 | export 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. | |
| 147 | export const fromResolved: QueryBuilderOptions = {} as QueryBuilderConfig | |
| 148 | export 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 | |
| 153 | export const backToResolved: QueryBuilderConfig = {} as QueryBuilderOptions | |
| @@ -4,7 +4,8 @@ | ||
| 4 | 4 | import type { SchemaMeta } from './meta' |
| 5 | 5 | import type { ResolvedPivot } from './pivot' |
| 6 | 6 | import type { DatabaseSchema } from './schema' |
| 7 | import { config, getPlaceholder, getPlaceholders, isMysqlLike } from './config' | |
| 7 | import type { QueryBuilderOptions } from './types' | |
| 8 | import { config, getPlaceholder, getPlaceholders, isMysqlLike, setConfig } from './config' | |
| 8 | 9 | import type { DriverConnection } from './db' |
| 9 | 10 | import { bunSql, getOrCreateBunSql, resetConnection } from './db' |
| 10 | 11 | import { resolvePivot } from './pivot' |
| @@ -5968,10 +5969,16 @@ export function createQueryBuilder<DB extends DatabaseSchema<any>>(state?: Parti | ||
| 5968 | 5969 | } |
| 5969 | 5970 | |
| 5970 | 5971 | 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) | |
| 5975 | 5982 | return this as any |
| 5976 | 5983 | }, |
| 5977 | 5984 | /** Escape/validate identifier names (best-effort) */ |
| @@ -1,4 +1,4 @@ | ||
| 1 | import type { QueryBuilderConfig, SupportedDialect } from './types' | |
| 1 | import type { QueryBuilderConfig, QueryBuilderOptions, SupportedDialect } from './types' | |
| 2 | 2 | import process from 'node:process' |
| 3 | 3 | import { loadConfig } from 'bunfig' |
| 4 | 4 | |
| @@ -78,6 +78,113 @@ export const defaultConfig: QueryBuilderConfig = { | ||
| 78 | 78 | }, |
| 79 | 79 | } |
| 80 | 80 | |
| 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 | */ | |
| 94 | const 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 | */ | |
| 104 | function 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 | */ | |
| 158 | function 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 | ||
| 81 | 188 | // The single, process-wide config object — stored on a `globalThis` symbol so |
| 82 | 189 | // that EVERY copy of this module shares one object, even if Bun's bundler |
| 83 | 190 | // inlines `config.ts` more than once. Previously this was an `export let` that |
| @@ -94,9 +201,15 @@ export const defaultConfig: QueryBuilderConfig = { | ||
| 94 | 201 | // is also shared across package versions in one process — that's the intended |
| 95 | 202 | // hardening; pin a versioned key if you ever need per-version isolation. |
| 96 | 203 | // - 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. | |
| 97 | 210 | const CONFIG_SINGLETON_KEY = Symbol.for('bun-query-builder.config') |
| 98 | 211 | export 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) | |
| 100 | 213 | |
| 101 | 214 | /** |
| 102 | 215 | * Config-precedence bookkeeping, on `globalThis` for the same reason `config` |
| @@ -110,6 +223,18 @@ export const config: QueryBuilderConfig | ||
| 110 | 223 | * - `explicit`: top-level keys passed to `setConfig()`. |
| 111 | 224 | * - `fileLoaded`: whether the config file has been read, so it is applied |
| 112 | 225 | * 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. | |
| 113 | 238 | */ |
| 114 | 239 | const CONFIG_STATE_KEY = Symbol.for('bun-query-builder.config-state') |
| 115 | 240 | interface ConfigState { explicit: Set<string>, fileLoaded: boolean } |
| @@ -199,6 +324,8 @@ export function getPlaceholders(count: number, startIndex = 1): string { | ||
| 199 | 324 | * |
| 200 | 325 | * So the loaded object is applied with those keys filtered out — an explicit API |
| 201 | 326 | * 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. | |
| 202 | 329 | * |
| 203 | 330 | * Note the load MUST pass `defaultConfig`: bunfig derives the environment |
| 204 | 331 | * variable names it recognises (`QUERY_BUILDER_*`) from the shape of the |
| @@ -214,7 +341,7 @@ export async function getConfig(): Promise<QueryBuilderConfig> { | ||
| 214 | 341 | defaultConfig, |
| 215 | 342 | }) |
| 216 | 343 | |
| 217 | const applicable: Partial<QueryBuilderConfig> = {} | |
| 344 | const applicable: QueryBuilderOptions = {} | |
| 218 | 345 | for (const [key, value] of Object.entries(loaded ?? {})) { |
| 219 | 346 | if (!configState.explicit.has(key)) |
| 220 | 347 | (applicable as Record<string, unknown>)[key] = value |
| @@ -264,41 +391,58 @@ const _warnedDialectConflicts = new Set<string>() | ||
| 264 | 391 | /** |
| 265 | 392 | * Merge a partial config into the process-wide singleton. |
| 266 | 393 | * |
| 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. | |
| 269 | 430 | */ |
| 270 | function 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) | |
| 431 | function applyConfig(userConfig: QueryBuilderOptions): void { | |
| 432 | const merged = mergeObject( | |
| 433 | config as unknown as Record<string, unknown>, | |
| 434 | userConfig as Record<string, unknown>, | |
| 435 | ) | |
| 282 | 436 | |
| 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] | |
| 298 | 442 | } |
| 299 | 443 | } |
| 300 | 444 | |
| 301 | export function setConfig(userConfig: Partial<QueryBuilderConfig>): void { | |
| 445 | export function setConfig(userConfig: QueryBuilderOptions): void { | |
| 302 | 446 | // Detect cross-instance dialect conflicts and warn once. The proper |
| 303 | 447 | // fix is per-instance config (stacksjs/stacks#1862 #18); this guard |
| 304 | 448 | // surfaces the symptom so callers can see the shared-state problem |
| @@ -308,10 +452,14 @@ export function setConfig(userConfig: Partial<QueryBuilderConfig>): void { | ||
| 308 | 452 | const key = `${_lastConfiguredDialect}->${userConfig.dialect}` |
| 309 | 453 | if (!_warnedDialectConflicts.has(key)) { |
| 310 | 454 | _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. | |
| 311 | 458 | 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. ` | |
| 315 | 463 | + `Run conflicting connections in separate processes — see stacksjs/stacks#1862 #18.`, |
| 316 | 464 | ) |
| 317 | 465 | } |
| @@ -325,3 +473,33 @@ export function setConfig(userConfig: Partial<QueryBuilderConfig>): void { | ||
| 325 | 473 | |
| 326 | 474 | applyConfig(userConfig) |
| 327 | 475 | } |
| 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 | */ | |
| 503 | export function defineConfig(userConfig: QueryBuilderOptions): QueryBuilderOptions { | |
| 504 | return userConfig | |
| 505 | } | |
| @@ -452,6 +452,95 @@ export interface QueryBuilderConfig { | ||
| 452 | 452 | } |
| 453 | 453 | } |
| 454 | 454 | |
| 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 | */ | |
| 517 | export 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 | */ | |
| 542 | export type QueryBuilderOptions = DeepPartial<QueryBuilderConfig> | |
| 543 | ||
| 455 | 544 | export interface CliOption { |
| 456 | 545 | verbose: boolean |
| 457 | 546 | } |
| @@ -0,0 +1,223 @@ | ||
| 1 | import { afterEach, beforeEach, describe, expect, it } from 'bun:test' | |
| 2 | import { 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. */ | |
| 27 | function 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 | ||
| 36 | let saved: Record<string, unknown> | |
| 37 | ||
| 38 | beforeEach(() => { | |
| 39 | saved = clone(config as unknown as Record<string, unknown>) | |
| 40 | }) | |
| 41 | ||
| 42 | afterEach(() => { | |
| 43 | for (const key of Object.keys(config)) | |
| 44 | delete (config as Record<string, unknown>)[key] | |
| 45 | Object.assign(config, saved) | |
| 46 | }) | |
| 47 | ||
| 48 | describe('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 | ||
| 162 | describe('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 | ||
| 211 | describe('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 | }) | |
| @@ -25,6 +25,9 @@ interface ProbeResult { | ||
| 25 | 25 | snapshotDir?: string |
| 26 | 26 | dialect?: string |
| 27 | 27 | verbose?: boolean |
| 28 | dbHost?: string | |
| 29 | dbPort?: number | |
| 30 | dbUrl?: string | |
| 28 | 31 | } |
| 29 | 32 | |
| 30 | 33 | /** |
| @@ -46,6 +49,9 @@ function probe(configFile: string, setup: string): ProbeResult { | ||
| 46 | 49 | snapshotDir: config.snapshotDir, |
| 47 | 50 | dialect: config.dialect, |
| 48 | 51 | verbose: config.verbose, |
| 52 | dbHost: config.database?.host, | |
| 53 | dbPort: config.database?.port, | |
| 54 | dbUrl: config.database?.url, | |
| 49 | 55 | })) |
| 50 | 56 | ` |
| 51 | 57 | |
| @@ -116,4 +122,55 @@ describe('config precedence', () => { | ||
| 116 | 122 | |
| 117 | 123 | expect(result.snapshotDir).toBe('.qb') |
| 118 | 124 | }) |
| 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 | }) | |
| 119 | 176 | }) |