also looking at this
feat(types): export QueryBuilderOptions, the shape an app actually writes
#1063
2 files
+68
-6
| @@ -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 | |
| @@ -214,7 +214,7 @@ export async function getConfig(): Promise<QueryBuilderConfig> { | ||
| 214 | 214 | defaultConfig, |
| 215 | 215 | }) |
| 216 | 216 | |
| 217 | const applicable: Partial<QueryBuilderConfig> = {} | |
| 217 | const applicable: QueryBuilderOptions = {} | |
| 218 | 218 | for (const [key, value] of Object.entries(loaded ?? {})) { |
| 219 | 219 | if (!configState.explicit.has(key)) |
| 220 | 220 | (applicable as Record<string, unknown>)[key] = value |
| @@ -267,7 +267,7 @@ const _warnedDialectConflicts = new Set<string>() | ||
| 267 | 267 | * Shared by `setConfig()` (explicit, marks keys) and `getConfig()` (from a |
| 268 | 268 | * file, does not) so the two paths can never drift on how nested objects merge. |
| 269 | 269 | */ |
| 270 | function applyConfig(userConfig: Partial<QueryBuilderConfig>): void { | |
| 270 | function applyConfig(userConfig: QueryBuilderOptions): void { | |
| 271 | 271 | // NEVER reassign `config` here (i.e. `config = { ...defaultConfig }`). |
| 272 | 272 | // Reassigning an `export let` triggers Bun's bundler to split the |
| 273 | 273 | // binding: the write goes to one identifier and every reader (e.g. |
| @@ -290,15 +290,30 @@ function applyConfig(userConfig: Partial<QueryBuilderConfig>): void { | ||
| 290 | 290 | if (userConfig.pagination) { |
| 291 | 291 | config.pagination = { ...config.pagination, ...userConfig.pagination } |
| 292 | 292 | } |
| 293 | // These two sections are optional on the resolved config, so merging only | |
| 294 | // over the CURRENT value cannot guarantee the required members survive a | |
| 295 | // partial override. Shallow `Partial` hid that by demanding a complete | |
| 296 | // section from the caller; a real partial makes it visible. Seeding from | |
| 297 | // `defaultConfig` is also the behaviour an app expects: setting | |
| 298 | // `softDeletes: { enabled: true }` should inherit `column` and | |
| 299 | // `defaultFilter`, not drop them. | |
| 293 | 300 | if (userConfig.softDeletes) { |
| 294 | config.softDeletes = { ...config.softDeletes, ...userConfig.softDeletes } | |
| 301 | config.softDeletes = { | |
| 302 | ...defaultConfig.softDeletes!, | |
| 303 | ...config.softDeletes, | |
| 304 | ...userConfig.softDeletes, | |
| 305 | } | |
| 295 | 306 | } |
| 296 | 307 | if (userConfig.vitess) { |
| 297 | config.vitess = { ...config.vitess, ...userConfig.vitess } | |
| 308 | config.vitess = { | |
| 309 | ...defaultConfig.vitess!, | |
| 310 | ...config.vitess, | |
| 311 | ...userConfig.vitess, | |
| 312 | } | |
| 298 | 313 | } |
| 299 | 314 | } |
| 300 | 315 | |
| 301 | export function setConfig(userConfig: Partial<QueryBuilderConfig>): void { | |
| 316 | export function setConfig(userConfig: QueryBuilderOptions): void { | |
| 302 | 317 | // Detect cross-instance dialect conflicts and warn once. The proper |
| 303 | 318 | // fix is per-instance config (stacksjs/stacks#1862 #18); this guard |
| 304 | 319 | // surfaces the symptom so callers can see the shared-state problem |
| @@ -452,6 +452,53 @@ export interface QueryBuilderConfig { | ||
| 452 | 452 | } |
| 453 | 453 | } |
| 454 | 454 | |
| 455 | /** | |
| 456 | * Every property optional, recursively, leaving arrays and functions intact. | |
| 457 | * | |
| 458 | * Plain `Partial` is only one level deep, which is not enough here: it would | |
| 459 | * still demand a complete `TimestampConfig` from anyone who wanted to rename a | |
| 460 | * single column. | |
| 461 | */ | |
| 462 | // `_args`, not `args`: this is a type-level signature, so the parameter name is | |
| 463 | // required syntax with nothing that could ever reference it. pickier's | |
| 464 | // no-unused-vars flags it all the same, and `^_` is the pattern it exempts. | |
| 465 | export type DeepPartial<T> = T extends (..._args: any[]) => any | |
| 466 | ? T | |
| 467 | : T extends readonly (infer U)[] | |
| 468 | ? readonly U[] | |
| 469 | : T extends object | |
| 470 | ? { [K in keyof T]?: DeepPartial<T[K]> } | |
| 471 | : T | |
| 472 | ||
| 473 | /** | |
| 474 | * # `QueryBuilderOptions` | |
| 475 | * | |
| 476 | * **What an application writes.** Every field is optional, because every field | |
| 477 | * has a default in {@link defaultConfig} — an app supplies only what it wants | |
| 478 | * to differ. | |
| 479 | * | |
| 480 | * Distinct from {@link QueryBuilderConfig}, which is the RESOLVED shape this | |
| 481 | * library reads after merging those defaults, and where a required field means | |
| 482 | * "guaranteed present by the time anything reads it". | |
| 483 | * | |
| 484 | * Conflating the two made every field added to the resolved config a | |
| 485 | * downstream compile error. 0.2.25 declared `migrationDir: string` without the | |
| 486 | * optional marker, and the app that consumed it had to hard-code | |
| 487 | * `'database/migrations'` — a value this package already defaults in three | |
| 488 | * separate places — purely to satisfy the type. `snapshotDir` did the same | |
| 489 | * thing one release earlier. See stacksjs/bun-query-builder#1062. | |
| 490 | * | |
| 491 | * `setConfig()` has always accepted a partial, so this names a contract the | |
| 492 | * library already honoured rather than introducing a new one. | |
| 493 | * | |
| 494 | * ```ts | |
| 495 | * export default { | |
| 496 | * dialect: 'postgres', | |
| 497 | * } satisfies QueryBuilderOptions | |
| 498 | * ``` | |
| 499 | */ | |
| 500 | export type QueryBuilderOptions = DeepPartial<QueryBuilderConfig> | |
| 501 | ||
| 455 | 502 | export interface CliOption { |
| 456 | 503 | verbose: boolean |
| 457 | 504 | } |