From c75bff48fad86ba355cd3dfee21a100dd3399ccc Mon Sep 17 00:00:00 2001 From: Andrew Zolotukhin Date: Mon, 6 Jul 2026 10:16:39 +0000 Subject: [PATCH] feat: add validate command to check entity schemas against live database - Implemented `validate` command in ORM CLI to check for schema drift without applying changes. - Added `validateEntitiesAgainstDatabase` function to validate entities against the live database. - Enhanced tests for the new validate command and its integration with the CLI. - Updated documentation to include usage of the new validate command. - Introduced URL-encoded body parsing in the server framework. - Added support for guarded upserts in the query builder. - Enhanced ActionResult with raw response handling for native Node integrations. --- .changeset/xpenser-framework-affordances.md | 27 +++ libs/client/README.md | 30 ++++ libs/client/src/cache.ts | 15 +- libs/client/src/middlewares/cacheTags.ts | 56 ++++--- .../src/middlewares/externalCacheTags.test.ts | 108 ++++++++++++ .../src/middlewares/externalCacheTags.ts | 97 +++++++++++ libs/env/README.md | 23 ++- libs/env/src/envBoolean.ts | 85 ++++++++++ libs/env/src/index.ts | 2 + libs/env/src/unit.test.ts | 92 +++++++++++ libs/knex-schema/README.md | 77 +++++++++ libs/knex-schema/src/SchemaQueryBuilder.ts | 8 +- libs/knex-schema/src/index.ts | 7 +- libs/knex-schema/src/migration.ts | 133 +++++++++++---- libs/knex-schema/src/operations/insert.ts | 155 ++++++++++++++++-- libs/knex-schema/src/orm.test.ts | 117 ++++++++++++- libs/knex-schema/src/types.ts | 36 ++++ libs/orm-cli/README.md | 16 ++ libs/orm-cli/src/cli.test.ts | 95 ++++++++++- libs/orm-cli/src/cli.ts | 10 +- libs/orm-cli/src/commands/validate.ts | 83 ++++++++++ libs/orm/README.md | 3 + libs/server/README.md | 37 ++++- libs/server/src/ActionResult.ts | 48 ++++++ .../src/ContentNegotiator.security.test.ts | 40 +++++ libs/server/src/ContentNegotiator.ts | 59 ++++++- libs/server/src/Endpoint.ts | 8 +- libs/server/src/Server.public.test.ts | 78 +++++++++ libs/server/src/Server.ts | 45 +++-- libs/server/src/index.ts | 7 + .../docs/app/client/sections/cacheTags.tsx | 32 +++- websites/docs/app/env/page.tsx | 36 +++- websites/docs/app/knex-schema/page.tsx | 62 +++++++ websites/docs/app/orm/page.tsx | 3 + websites/docs/app/server/page.tsx | 31 +++- 35 files changed, 1652 insertions(+), 109 deletions(-) create mode 100644 .changeset/xpenser-framework-affordances.md create mode 100644 libs/client/src/middlewares/externalCacheTags.test.ts create mode 100644 libs/client/src/middlewares/externalCacheTags.ts create mode 100644 libs/env/src/envBoolean.ts create mode 100644 libs/orm-cli/src/commands/validate.ts diff --git a/.changeset/xpenser-framework-affordances.md b/.changeset/xpenser-framework-affordances.md new file mode 100644 index 00000000..f2c92ef7 --- /dev/null +++ b/.changeset/xpenser-framework-affordances.md @@ -0,0 +1,27 @@ +--- +'@cleverbrush/client': minor +'@cleverbrush/env': minor +'@cleverbrush/knex-schema': minor +'@cleverbrush/orm': minor +'@cleverbrush/orm-cli': minor +'@cleverbrush/server': minor +--- + +Add framework affordances discovered while reviewing Xpenser: + +- `@cleverbrush/env`: add `envBoolean()` for environment-style boolean values + such as `1`, `0`, `yes`, `no`, `on`, and `off`. +- `@cleverbrush/server`: parse `application/x-www-form-urlencoded` request + bodies by default, add `ActionResult.raw()`, and allow ordered + authentication fallback with `trySchemes`. +- `@cleverbrush/client`: add `externalCacheTags()` to + `@cleverbrush/client/cache` for framework-agnostic external cache tag + invalidation, including Next.js `revalidateTag()` as one supported callback. +- `@cleverbrush/knex-schema` / `@cleverbrush/orm`: add + `InferDatabaseRow` / `InferDatabaseValue` row typing helpers and support raw + update expressions plus conditional `where` callbacks in + `onConflict().merge()`. +- `@cleverbrush/orm-cli`: add read-only `cb-orm validate` for live schema drift + checks. + +Docs and package READMEs were updated; see https://docs.cleverbrush.com. diff --git a/libs/client/README.md b/libs/client/README.md index d7364325..a4d8649a 100644 --- a/libs/client/README.md +++ b/libs/client/README.md @@ -280,6 +280,36 @@ automatically invalidate the query cache for the affected group — no manual See the [server-side cache tags](/server#cache-tags) section for how to declare tags on your endpoints. +### External Cache Tags — `@cleverbrush/client/cache` + +Use `externalCacheTags()` to bridge endpoint `.clearsCacheTag()` metadata to +any cache system that accepts tag invalidation: + +```ts +import { createClient } from '@cleverbrush/client'; +import { externalCacheTags } from '@cleverbrush/client/cache'; + +const client = createClient(api, { + middlewares: [ + externalCacheTags({ + invalidateTag: async tag => externalCache.invalidate(tag), + }), + ], +}); +``` + +For Next.js, pass `revalidateTag` as the invalidation callback: + +```ts +import { revalidateTag } from 'next/cache'; + +externalCacheTags({ invalidateTag: revalidateTag }); +``` + +The middleware runs after successful `POST`, `PUT`, `PATCH`, and `DELETE` +responses. Dynamic tags invalidate both the base tag name and the computed key +by default, for example `expense` and `expense:id=42`. + ## Per-Call Overrides Override middleware options for individual calls: diff --git a/libs/client/src/cache.ts b/libs/client/src/cache.ts index 605e98ed..04356649 100644 --- a/libs/client/src/cache.ts +++ b/libs/client/src/cache.ts @@ -1,4 +1,15 @@ export type { CacheOptions } from './middlewares/cache.js'; export { throttlingCache } from './middlewares/cache.js'; -export type { CacheTagMiddlewareOptions } from './middlewares/cacheTags.js'; -export { cacheTags } from './middlewares/cacheTags.js'; +export type { + CacheTagMiddlewareOptions, + CacheTagRoot, + SerializedCacheTag +} from './middlewares/cacheTags.js'; +export { + cacheTags, + computeCacheTagKey, + createCacheTagRoot, + isMutatingMethod +} from './middlewares/cacheTags.js'; +export type { ExternalCacheTagsOptions } from './middlewares/externalCacheTags.js'; +export { externalCacheTags } from './middlewares/externalCacheTags.js'; diff --git a/libs/client/src/middlewares/cacheTags.ts b/libs/client/src/middlewares/cacheTags.ts index 39910a49..d6ad5b09 100644 --- a/libs/client/src/middlewares/cacheTags.ts +++ b/libs/client/src/middlewares/cacheTags.ts @@ -50,21 +50,23 @@ export interface CacheTagMiddlewareOptions { /** * The root object passed to each `CacheTagPropertyAccessor.getValue()` call. */ -interface TagRoot { +export interface CacheTagRoot { params: Record; body: unknown; query: Record; headers: Record; } -/** Shape of a serialised cache tag from endpoint metadata. */ -interface SerializedCacheTag { +/** + * Shape of a serialized cache tag from endpoint metadata. + */ +export interface SerializedCacheTag { name: string; properties: Readonly< Record< string, { - getValue(root: TagRoot): { + getValue(root: CacheTagRoot): { value?: unknown; success: boolean; }; @@ -82,7 +84,10 @@ interface CacheEntry { expiresAt: number; } -function isMutating(method: string): boolean { +/** + * Return `true` for HTTP methods that mutate server state. + */ +export function isMutatingMethod(method: string): boolean { return ['POST', 'PUT', 'DELETE', 'PATCH'].includes(method.toUpperCase()); } @@ -93,7 +98,10 @@ function isMutating(method: string): boolean { * - Tags with properties produce `name:key1=val1,key2=val2` where * keys are sorted alphabetically for determinism. */ -function computeKey(tag: SerializedCacheTag, root: TagRoot): string { +export function computeCacheTagKey( + tag: SerializedCacheTag, + root: CacheTagRoot +): string { const entries = Object.entries(tag.properties); if (entries.length === 0) { @@ -117,6 +125,18 @@ function computeKey(tag: SerializedCacheTag, root: TagRoot): string { return `${tag.name}:${parts.join(',')}`; } +/** + * Build the cache-tag accessor root from endpoint metadata. + */ +export function createCacheTagRoot(meta: EndpointMeta): CacheTagRoot { + return { + params: (meta.params as Record) ?? {}, + body: meta.body, + query: (meta.query as Record) ?? {}, + headers: (meta.headers as Record) ?? {} + }; +} + // --------------------------------------------------------------------------- // Middleware // --------------------------------------------------------------------------- @@ -149,16 +169,11 @@ export function cacheTags(options: CacheTagMiddlewareOptions = {}): Middleware { const method = (init.method ?? 'GET').toUpperCase(); // -- Invalidation on mutating requests -- - if (isMutating(method) && tags && tags.length > 0) { - const root: TagRoot = { - params: (meta?.params as Record) ?? {}, - body: meta?.body, - query: (meta?.query as Record) ?? {}, - headers: (meta?.headers as Record) ?? {} - }; + if (isMutatingMethod(method) && meta && tags && tags.length > 0) { + const root = createCacheTagRoot(meta); for (const tag of tags) { - const tagKey = computeKey(tag, root); + const tagKey = computeCacheTagKey(tag, root); // Invalidate the exact key and any prefixed variants // (tag name prefix match handles dynamic property variants // when the mutation didn't provide the same properties). @@ -174,18 +189,13 @@ export function cacheTags(options: CacheTagMiddlewareOptions = {}): Middleware { } // -- Cache lookup for GET requests -- - if (method === 'GET' && tags && tags.length > 0) { - const root: TagRoot = { - params: (meta?.params as Record) ?? {}, - body: meta?.body, - query: (meta?.query as Record) ?? {}, - headers: (meta?.headers as Record) ?? {} - }; + if (method === 'GET' && meta && tags && tags.length > 0) { + const root = createCacheTagRoot(meta); let foundEntry: CacheEntry | undefined; for (const tag of tags) { - const cacheKey = computeKey(tag, root); + const cacheKey = computeCacheTagKey(tag, root); const entry = cache.get(cacheKey); if (entry && entry.expiresAt > Date.now()) { foundEntry = entry; @@ -203,7 +213,7 @@ export function cacheTags(options: CacheTagMiddlewareOptions = {}): Middleware { return next(url, init).then(response => { if (condition(response)) { for (const tag of tags) { - const cacheKey = computeKey(tag, root); + const cacheKey = computeCacheTagKey(tag, root); const ttl = ttlByTag[tag.name] !== undefined ? ttlByTag[tag.name] diff --git a/libs/client/src/middlewares/externalCacheTags.test.ts b/libs/client/src/middlewares/externalCacheTags.test.ts new file mode 100644 index 00000000..8a6ddfc8 --- /dev/null +++ b/libs/client/src/middlewares/externalCacheTags.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, test, vi } from 'vitest'; +import type { EndpointMeta, FetchLike } from '../middleware.js'; +import { externalCacheTags } from './externalCacheTags.js'; + +function makeMeta(overrides: Partial = {}): EndpointMeta { + return { + group: 'expenses', + endpoint: 'update', + method: 'PATCH', + path: '/api/expenses/:id', + basePath: '/api/expenses/:id', + collectionPath: '/api/expenses', + baseUrl: '', + fullCollectionUrl: '/api/expenses', + pathParamNames: ['id'], + params: { id: 42 }, + body: undefined, + query: {}, + headers: {}, + operationId: null, + tags: [], + cacheTags: [ + { + name: 'expense', + properties: { + id: { + getValue: root => ({ + success: true, + value: root.params.id + }) + } + } + } + ], + ...overrides + }; +} + +describe('externalCacheTags middleware', () => { + test('invalidates base and dynamic tags after a successful mutation', async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response(null, { status: 204 })); + const invalidateTag = vi.fn(); + const middleware = externalCacheTags({ invalidateTag })(fetch); + + const response = await middleware('/api/expenses/42', { + method: 'PATCH', + __endpointMeta: makeMeta() + } as any); + + expect(response.status).toBe(204); + expect(fetch).toHaveBeenCalledTimes(1); + expect(invalidateTag).toHaveBeenCalledWith('expense'); + expect(invalidateTag).toHaveBeenCalledWith('expense:id=42'); + expect(invalidateTag).toHaveBeenCalledTimes(2); + }); + + test('does not invalidate failed mutation responses by default', async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response('bad', { status: 400 })); + const invalidateTag = vi.fn(); + const middleware = externalCacheTags({ invalidateTag })(fetch); + + await middleware('/api/expenses/42', { + method: 'PATCH', + __endpointMeta: makeMeta() + } as any); + + expect(invalidateTag).not.toHaveBeenCalled(); + }); + + test('does not invalidate reads or requests without cache metadata', async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response(null, { status: 200 })); + const invalidateTag = vi.fn(); + const middleware = externalCacheTags({ invalidateTag })(fetch); + + await middleware('/api/expenses/42', { + method: 'GET', + __endpointMeta: makeMeta({ method: 'GET' }) + } as any); + await middleware('/api/expenses/42', { method: 'PATCH' }); + + expect(invalidateTag).not.toHaveBeenCalled(); + }); + + test('can invalidate only computed dynamic keys', async () => { + const fetch = vi + .fn() + .mockResolvedValue(new Response(null, { status: 200 })); + const invalidateTag = vi.fn(); + const middleware = externalCacheTags({ + invalidateTag, + invalidateBaseTags: false + })(fetch); + + await middleware('/api/expenses/42', { + method: 'DELETE', + __endpointMeta: makeMeta() + } as any); + + expect(invalidateTag).toHaveBeenCalledWith('expense:id=42'); + expect(invalidateTag).toHaveBeenCalledTimes(1); + }); +}); diff --git a/libs/client/src/middlewares/externalCacheTags.ts b/libs/client/src/middlewares/externalCacheTags.ts new file mode 100644 index 00000000..cbc161af --- /dev/null +++ b/libs/client/src/middlewares/externalCacheTags.ts @@ -0,0 +1,97 @@ +import type { EndpointMeta, Middleware } from '../middleware.js'; +import { + computeCacheTagKey, + createCacheTagRoot, + isMutatingMethod, + type SerializedCacheTag +} from './cacheTags.js'; + +/** + * Configuration for {@link externalCacheTags}. + */ +export interface ExternalCacheTagsOptions { + /** + * Callback invoked for each tag that should be invalidated in an external + * cache system. + * + * Pass any framework or platform cache invalidation function here. For + * example, Next.js users can pass `revalidateTag` from `next/cache`. + */ + invalidateTag: (tag: string) => void | Promise; + + /** + * Predicate that decides whether a response should trigger invalidation. + * + * @defaultValue `(response) => response.ok` + */ + condition?: (response: Response) => boolean; + + /** + * Also invalidate the base tag name when a dynamic tag key is computed. + * + * @defaultValue `true` + */ + invalidateBaseTags?: boolean; +} + +/** + * Create a middleware that bridges endpoint cache tags to an external cache. + * + * The middleware reads endpoint `.cacheTag()` / `.clearsCacheTag()` metadata + * from client requests and calls `invalidateTag()` after successful mutating + * requests (`POST`, `PUT`, `PATCH`, or `DELETE`). Dynamic tags invalidate both + * the base tag name and the computed key by default. + * + * @param options - External cache-tag invalidation options. + * @returns A client middleware for `createClient()`. + * + * @example + * ```ts + * import { revalidateTag } from 'next/cache'; + * import { externalCacheTags } from '@cleverbrush/client/cache'; + * + * const client = createClient(api, { + * middlewares: [ + * externalCacheTags({ invalidateTag: revalidateTag }), + * ], + * }); + * ``` + */ +export function externalCacheTags( + options: ExternalCacheTagsOptions +): Middleware { + const { + invalidateTag, + condition = (response: Response) => response.ok, + invalidateBaseTags = true + } = options; + + return next => async (url, init) => { + const response = await next(url, init); + const meta = (init as any).__endpointMeta as EndpointMeta | undefined; + const method = (init.method ?? meta?.method ?? 'GET').toUpperCase(); + const tags: readonly SerializedCacheTag[] | undefined = meta?.cacheTags; + + if ( + !meta || + !tags || + tags.length === 0 || + !isMutatingMethod(method) || + !condition(response) + ) { + return response; + } + + const root = createCacheTagRoot(meta); + const tagKeys = new Set(); + + for (const tag of tags) { + const dynamicKey = computeCacheTagKey(tag, root); + if (invalidateBaseTags) tagKeys.add(tag.name); + tagKeys.add(dynamicKey); + } + + await Promise.all([...tagKeys].map(tag => invalidateTag(tag))); + return response; + }; +} diff --git a/libs/env/README.md b/libs/env/README.md index b14f4dcf..65d13f6d 100644 --- a/libs/env/README.md +++ b/libs/env/README.md @@ -45,8 +45,8 @@ npm install @cleverbrush/env @cleverbrush/schema ### Structured config (nested) ```typescript -import { env, parseEnv, splitBy } from '@cleverbrush/env'; -import { string, number, boolean, array } from '@cleverbrush/schema'; +import { env, envBoolean, parseEnv, splitBy } from '@cleverbrush/env'; +import { string, number, array } from '@cleverbrush/schema'; const config = parseEnv({ db: { @@ -57,7 +57,7 @@ const config = parseEnv({ jwt: { secret: env('JWT_SECRET', string().minLength(32)), }, - debug: env('DEBUG', boolean().coerce().default(false)), + debug: env('DEBUG', envBoolean().default(false)), allowedOrigins: env( 'ALLOWED_ORIGINS', array(string()).addPreprocessor(splitBy(','), { mutates: false }) @@ -115,6 +115,22 @@ env('PORTS', array(number().coerce()).addPreprocessor(splitBy(','), { mutates: f // "3000, 4000" → [3000, 4000] ``` +### Environment booleans + +Use `envBoolean()` for shell-style boolean flags. It accepts `true` / `false`, +`1` / `0`, `yes` / `no`, and `on` / `off` by default: + +```typescript +import { env, envBoolean } from '@cleverbrush/env'; + +env('FEATURE_ENABLED', envBoolean().default(false)); +// FEATURE_ENABLED=1 → true +// FEATURE_ENABLED=off → false +``` + +Pass `trueValues`, `falseValues`, and `caseSensitive` when a project has its +own flag vocabulary. + ### Error reporting When variables are missing or invalid, `EnvValidationError` is thrown with a formatted message: @@ -188,6 +204,7 @@ const config = parseEnv( | `parseEnv(config, source?)` | Function | Parses env vars into a validated, typed nested config object. | | `parseEnv(config, compute, source?)` | Function | Parses env vars, then deep-merges computed values from the callback. | | `parseEnvFlat(schemas, source?)` | Function | Flat convenience — keys are env var names, no `env()` needed. | +| `envBoolean(options?)` | Function | Boolean schema for env-style values such as `1`, `0`, `yes`, `no`, `on`, `off`. | | `splitBy(separator)` | Function | Preprocessor that splits a string into an array. | | `EnvValidationError` | Class | Thrown when env vars are missing or invalid. Has `.missing` and `.invalid`. | | `EnvField` | Type | Branded wrapper type created by `env()`. | diff --git a/libs/env/src/envBoolean.ts b/libs/env/src/envBoolean.ts new file mode 100644 index 00000000..a44e036a --- /dev/null +++ b/libs/env/src/envBoolean.ts @@ -0,0 +1,85 @@ +import { boolean } from '@cleverbrush/schema'; + +/** + * Options for {@link envBoolean}. + */ +export interface EnvBooleanOptions { + /** + * String values accepted as `true`. + * + * @defaultValue `['true', '1', 'yes', 'on']` + */ + trueValues?: readonly string[]; + + /** + * String values accepted as `false`. + * + * @defaultValue `['false', '0', 'no', 'off']` + */ + falseValues?: readonly string[]; + + /** + * Whether string matching is case-sensitive. + * + * @defaultValue `false` + */ + caseSensitive?: boolean; + + /** + * Whether to trim surrounding whitespace before matching. + * + * @defaultValue `true` + */ + trim?: boolean; +} + +const DEFAULT_TRUE_VALUES = ['true', '1', 'yes', 'on'] as const; +const DEFAULT_FALSE_VALUES = ['false', '0', 'no', 'off'] as const; + +/** + * Create a boolean schema tuned for environment variables. + * + * The base schema `boolean().coerce()` intentionally accepts only + * `"true"`/`"false"`. Environment variables often use shell-style toggles + * such as `1`, `0`, `yes`, `no`, `on`, and `off`, so this helper normalizes + * those values before boolean validation runs. + * + * @param options - Matching behavior and accepted true/false strings. + * @returns A boolean schema builder with env-style string preprocessing. + * + * @example + * ```ts + * const config = parseEnv({ + * debug: env('DEBUG', envBoolean().default(false)), + * }); + * ``` + */ +export function envBoolean(options: EnvBooleanOptions = {}) { + const { + trueValues = DEFAULT_TRUE_VALUES, + falseValues = DEFAULT_FALSE_VALUES, + caseSensitive = false, + trim = true + } = options; + + const normalize = (value: string): string => { + const trimmed = trim ? value.trim() : value; + return caseSensitive ? trimmed : trimmed.toLowerCase(); + }; + + const trueSet = new Set(trueValues.map(normalize)); + const falseSet = new Set(falseValues.map(normalize)); + + return boolean().addPreprocessor( + value => { + if (typeof value !== 'string') return value; + + const normalized = normalize(value); + if (trueSet.has(normalized)) return true; + if (falseSet.has(normalized)) return false; + + return value; + }, + { mutates: true } + ); +} diff --git a/libs/env/src/index.ts b/libs/env/src/index.ts index 827aa613..846d301d 100644 --- a/libs/env/src/index.ts +++ b/libs/env/src/index.ts @@ -1,5 +1,7 @@ // Core API export { env } from './env.js'; +export type { EnvBooleanOptions } from './envBoolean.js'; +export { envBoolean } from './envBoolean.js'; export type { InvalidEnvVar, MissingEnvVar } from './errors.js'; // Error class export { EnvValidationError } from './errors.js'; diff --git a/libs/env/src/unit.test.ts b/libs/env/src/unit.test.ts index 12c70bf9..625077fe 100644 --- a/libs/env/src/unit.test.ts +++ b/libs/env/src/unit.test.ts @@ -3,6 +3,7 @@ import { describe, expect, expectTypeOf, it } from 'vitest'; import { EnvValidationError, env, + envBoolean, parseEnv, parseEnvFlat, splitBy @@ -39,6 +40,97 @@ describe('splitBy()', () => { }); }); +describe('envBoolean()', () => { + it('accepts common env-style true values', () => { + for (const value of ['true', 'TRUE', '1', 'yes', 'on', ' On ']) { + const config = parseEnv( + { + enabled: env('FEATURE_ENABLED', envBoolean()) + }, + { FEATURE_ENABLED: value } + ); + + expect(config.enabled).toBe(true); + expectTypeOf(config.enabled).toBeBoolean(); + } + }); + + it('accepts common env-style false values', () => { + for (const value of ['false', 'FALSE', '0', 'no', 'off', ' Off ']) { + const config = parseEnv( + { + enabled: env('FEATURE_ENABLED', envBoolean()) + }, + { FEATURE_ENABLED: value } + ); + + expect(config.enabled).toBe(false); + expectTypeOf(config.enabled).toBeBoolean(); + } + }); + + it('respects custom truthy and falsy values', () => { + const config = parseEnv( + { + enabled: env( + 'FEATURE_ENABLED', + envBoolean({ + trueValues: ['enabled'], + falseValues: ['disabled'] + }) + ) + }, + { FEATURE_ENABLED: 'enabled' } + ); + + expect(config.enabled).toBe(true); + }); + + it('can match case-sensitive values', () => { + const config = parseEnv( + { + enabled: env( + 'FEATURE_ENABLED', + envBoolean({ + trueValues: ['YES'], + falseValues: ['NO'], + caseSensitive: true + }) + ) + }, + { FEATURE_ENABLED: 'YES' } + ); + + expect(config.enabled).toBe(true); + expect(() => + parseEnv( + { + enabled: env( + 'FEATURE_ENABLED', + envBoolean({ + trueValues: ['YES'], + falseValues: ['NO'], + caseSensitive: true + }) + ) + }, + { FEATURE_ENABLED: 'yes' } + ) + ).toThrow(EnvValidationError); + }); + + it('leaves unknown values for boolean validation', () => { + expect(() => + parseEnv( + { + enabled: env('FEATURE_ENABLED', envBoolean()) + }, + { FEATURE_ENABLED: 'sometimes' } + ) + ).toThrow(EnvValidationError); + }); +}); + describe('parseEnv()', () => { describe('flat config with env()', () => { it('parses string values', () => { diff --git a/libs/knex-schema/README.md b/libs/knex-schema/README.md index 98d16c36..594e6958 100644 --- a/libs/knex-schema/README.md +++ b/libs/knex-schema/README.md @@ -100,6 +100,65 @@ const newUsers = await query(db, UserSchema).insertMany([ ]); ``` +### Guarded upsert for imports + +Use `onConflict().merge()` when an import or sync job should insert a row if it +does not exist, but update it only when the incoming data is newer than the row +already stored in PostgreSQL. + +In this example, products are imported from an external system. The `sku` is the +unique key. If the product already exists, the price is updated only when the +incoming `sourceUpdatedAt` timestamp is newer: + +```typescript +await query(db, ProductSchema) + .onConflict(t => t.sku) + .merge( + { + sku: 'SKU-123', + price: 1999, + sourceUpdatedAt: new Date('2026-01-15T10:00:00Z'), + }, + { + price: ({ excluded }) => excluded(t => t.price), + sourceUpdatedAt: ({ excluded }) => + excluded(t => t.sourceUpdatedAt), + syncedAt: ({ knex }) => knex.fn.now(), + }, + { + where: (qb, { column }) => { + qb.whereRaw('?? < excluded.??', [ + column(t => t.sourceUpdatedAt), + column(t => t.sourceUpdatedAt), + ]); + }, + } + ); +``` + +What each helper does: + +- `excluded(t => t.price)` means "use the incoming value from the failed + insert", producing `excluded."price"` in SQL. +- `knex.fn.now()` sets `synced_at` to the database current timestamp. +- `column(t => t.sourceUpdatedAt)` resolves the schema property to the mapped + SQL column name, for example `source_updated_at`. +- `where` attaches a PostgreSQL `ON CONFLICT DO UPDATE WHERE ...` guard. If the + guard is false, PostgreSQL leaves the existing row unchanged. + +Approximate SQL: + +```sql +insert into "products" ("sku", "price", "source_updated_at") +values (?, ?, ?) +on conflict ("sku") do update set + "price" = excluded."price", + "source_updated_at" = excluded."source_updated_at", + "synced_at" = CURRENT_TIMESTAMP +where "source_updated_at" < excluded."source_updated_at" +returning * +``` + ### Update ```typescript @@ -326,6 +385,12 @@ export const PostEntity = defineEntity(PostSchema) The returned `Entity` carries the relation map in its type, so downstream `query(db, entity)` calls (and `@cleverbrush/orm`'s `DbSet.include()`) get full inference. +For many-to-many replacement flows, use the link table directly: delete the +current links in a transaction, `insertMany()` the desired links, and use +`onConflict(...).ignore()` when inserts may race with another writer. This keeps +the framework primitive generic while still covering tag/category sync +workflows. + ### Polymorphism (STI / CTI) Mark a schema as polymorphic to support single-table or class-table inheritance — variants @@ -346,12 +411,24 @@ See the `@cleverbrush/orm` docs for the full inheritance API (`.ofVariant()` etc | `generateMigrationsForContext(entities, prevSnapshot)` | Diff entities against the snapshot and emit a TS migration source plus the next snapshot | | `generateMigration(snapshotA, snapshotB)` | Lower-level snapshot-vs-snapshot diff | | `diffSchema(schema, dbState)` / `applyDiff(knex, diff, table)` | Live-database diff/apply (used by `cb-orm db push`) | +| `validateEntitiesAgainstDatabase(knex, entities)` | Read-only live-database validation for CI drift checks | | `introspectDatabase(knex, table)` / `tableExistsInDb(knex, table)` | Database introspection helpers | | `generateCreateTable(schema)` / `generateCreatePolymorphicTables(schema)` | Knex-statement builders for fresh `CREATE TABLE` | Most users invoke these indirectly through the [`cb-orm`](https://www.npmjs.com/package/@cleverbrush/orm-cli) CLI (`cb-orm migrate generate`, `cb-orm db push`). +### Row typing helpers + +Use `InferDatabaseRow` when a raw row or mapper can receive SQL +`NULL` for optional schema properties: + +```typescript +import type { InferDatabaseRow } from '@cleverbrush/knex-schema'; + +type UserRow = InferDatabaseRow; +``` + ### Row-version optimistic concurrency Mark a column as a row version with `.rowVersion()` to opt-in to optimistic concurrency diff --git a/libs/knex-schema/src/SchemaQueryBuilder.ts b/libs/knex-schema/src/SchemaQueryBuilder.ts index 127d635c..81e6fd08 100644 --- a/libs/knex-schema/src/SchemaQueryBuilder.ts +++ b/libs/knex-schema/src/SchemaQueryBuilder.ts @@ -35,7 +35,13 @@ import type { SelectSelector } from './types.js'; -export { OnConflictBuilder } from './operations/insert.js'; +export { + OnConflictBuilder, + type OnConflictMergeHelpers, + type OnConflictMergeOptions, + type OnConflictUpdateData, + type OnConflictUpdateValue +} from './operations/insert.js'; import { deleteImpl, diff --git a/libs/knex-schema/src/index.ts b/libs/knex-schema/src/index.ts index 892b10b5..2f2387ee 100644 --- a/libs/knex-schema/src/index.ts +++ b/libs/knex-schema/src/index.ts @@ -63,12 +63,15 @@ export { clearRow, MAPPERS, mapObject, mapValue } from './mappers.js'; export { applyDiff, diffSchema, + type EntitySchemaValidationIssue, + type EntitySchemaValidationResult, entitySchemaToTableState, generateMigration, generateMigrationsForContext, introspectDatabase, isDiffEmpty, - tableExistsInDb + tableExistsInDb, + validateEntitiesAgainstDatabase } from './migration.js'; // Raw query execution export { rawQuery } from './raw.js'; @@ -99,6 +102,8 @@ export type { DatabaseForeignKeyInfo, DatabaseIndexInfo, DatabaseTableState, + InferDatabaseRow, + InferDatabaseValue, InsertType, JoinManySpec, JoinOneSpec, diff --git a/libs/knex-schema/src/migration.ts b/libs/knex-schema/src/migration.ts index f8b3bd18..fc923d5f 100644 --- a/libs/knex-schema/src/migration.ts +++ b/libs/knex-schema/src/migration.ts @@ -585,6 +585,75 @@ export async function tableExistsInDb( return knex.schema.hasTable(tableName); } +// --------------------------------------------------------------------------- +// validateEntitiesAgainstDatabase +// --------------------------------------------------------------------------- + +/** + * A schema validation issue detected against the live database. + */ +export type EntitySchemaValidationIssue = + | { + type: 'missing-table'; + tableName: string; + } + | { + type: 'schema-drift'; + tableName: string; + diff: MigrationDiff; + }; + +/** + * Result returned by {@link validateEntitiesAgainstDatabase}. + */ +export interface EntitySchemaValidationResult { + /** `true` when every entity table exists and has an empty schema diff. */ + valid: boolean; + /** Table names inspected during validation. */ + checkedTables: string[]; + /** Missing tables or non-empty schema diffs. */ + issues: EntitySchemaValidationIssue[]; +} + +/** + * Validate entity schemas against a live database without changing anything. + * + * This is the read-only counterpart to `db push`: it checks every registered + * entity table, including class-table-inheritance variant tables, and reports + * missing tables or schema drift. + * + * @param knex - A configured Knex instance or transaction. + * @param entities - The entity definitions to validate. + * @returns A validation result with all detected issues. + */ +export async function validateEntitiesAgainstDatabase( + knex: Knex, + entities: Entity[] +): Promise { + const unique = collectUniqueEntityTables(entities); + const issues: EntitySchemaValidationIssue[] = []; + + for (const { schema, tableName } of unique) { + const exists = await tableExistsInDb(knex, tableName); + if (!exists) { + issues.push({ type: 'missing-table', tableName }); + continue; + } + + const dbState = await introspectDatabase(knex, tableName); + const diff = diffSchema(schema, dbState); + if (!isDiffEmpty(diff)) { + issues.push({ type: 'schema-drift', tableName, diff }); + } + } + + return { + valid: issues.length === 0, + checkedTables: unique.map(entry => entry.tableName), + issues + }; +} + // --------------------------------------------------------------------------- // isDiffEmpty // --------------------------------------------------------------------------- @@ -786,34 +855,7 @@ export function generateMigrationsForContext( nextSnapshot: SchemaSnapshot; } { // 1. Collect all (schema, tableName) pairs including CTI variant tables - const tableEntries: { - schema: ObjectSchemaBuilder; - tableName: string; - }[] = []; - - for (const entity of entities) { - const schema = entity.schema; - const tableName = getTableName(schema); - tableEntries.push({ schema, tableName }); - - // CTI variant tables each have their own tableName extension set - for (const vs of getPolymorphicVariantSchemas(schema)) { - const variantTableName = vs.getExtension('tableName') as - | string - | undefined; - if (variantTableName) { - tableEntries.push({ schema: vs, tableName: variantTableName }); - } - } - } - - // 2. Deduplicate (STI variants share the base table) - const seen = new Set(); - const uniqueEntries = tableEntries.filter(e => { - if (seen.has(e.tableName)) return false; - seen.add(e.tableName); - return true; - }); + const uniqueEntries = collectUniqueEntityTables(entities); // 3. Topological sort by FK dependencies const sorted = topologicalSort(uniqueEntries); @@ -1099,6 +1141,41 @@ function buildColumnFromDbType( } } +function collectUniqueEntityTables(entities: Entity[]): { + schema: ObjectSchemaBuilder; + tableName: string; +}[] { + const tableEntries: { + schema: ObjectSchemaBuilder; + tableName: string; + }[] = []; + + for (const entity of entities) { + const schema = entity.schema; + const tableName = getTableName(schema); + tableEntries.push({ schema, tableName }); + + for (const variantSchema of getPolymorphicVariantSchemas(schema)) { + const variantTableName = variantSchema.getExtension('tableName') as + | string + | undefined; + if (variantTableName) { + tableEntries.push({ + schema: variantSchema, + tableName: variantTableName + }); + } + } + } + + const seen = new Set(); + return tableEntries.filter(entry => { + if (seen.has(entry.tableName)) return false; + seen.add(entry.tableName); + return true; + }); +} + /** * @internal * Topologically sort table entries by FK dependency so parent tables diff --git a/libs/knex-schema/src/operations/insert.ts b/libs/knex-schema/src/operations/insert.ts index 82b09609..5961a244 100644 --- a/libs/knex-schema/src/operations/insert.ts +++ b/libs/knex-schema/src/operations/insert.ts @@ -1,8 +1,8 @@ // @cleverbrush/knex-schema — INSERT / upsert / bulk operations -import type { InferType } from '@cleverbrush/schema'; +import type { InferType, ObjectSchemaBuilder } from '@cleverbrush/schema'; import type { Knex } from 'knex'; -import { buildColumnMap } from '../columns.js'; +import { buildColumnMap, resolveColumnRef } from '../columns.js'; import { getTableName } from '../extension.js'; import type { SchemaQueryBuilder } from '../SchemaQueryBuilder.js'; import type { ColumnRef, InsertType } from '../types.js'; @@ -18,18 +18,70 @@ import { getState } from './state.js'; // OnConflictBuilder // --------------------------------------------------------------------------- -export class OnConflictBuilder< - TLocalSchema extends import('@cleverbrush/schema').ObjectSchemaBuilder< - any, - any, - any, - any, - any, - any, - any - >, - TResult -> { +type AnyObjectSchema = ObjectSchemaBuilder; + +/** + * Helpers available to `onConflict().merge()` update expressions and + * conditional `where` callbacks. + */ +export interface OnConflictMergeHelpers { + /** The Knex instance used by the query builder. */ + readonly knex: Knex; + + /** + * Resolve a schema property reference to its mapped database column name. + */ + column(ref: ColumnRef): string; + + /** + * Create a raw SQL expression with Knex bindings. + */ + raw(sql: string, bindings?: readonly unknown[]): Knex.Raw; + + /** + * Reference the `excluded.` value in an upsert merge expression. + */ + excluded(ref: ColumnRef): Knex.Raw; +} + +/** + * Value accepted in explicit `onConflict().merge(data, updateData)` payloads. + */ +export type OnConflictUpdateValue< + TLocalSchema extends AnyObjectSchema, + TValue +> = + | TValue + | Knex.Raw + | ((helpers: OnConflictMergeHelpers) => TValue | Knex.Raw); + +/** + * Explicit update payload accepted by `onConflict().merge()`. + */ +export type OnConflictUpdateData = + Partial<{ + [K in keyof InferType]: OnConflictUpdateValue< + TLocalSchema, + InferType[K] + >; + }>; + +/** + * Options for `onConflict().merge()`. + */ +export interface OnConflictMergeOptions { + /** + * Attach a conditional `WHERE` clause to the generated merge. + * + * This is useful for row-version or timestamp guarded upserts. + */ + where?: ( + query: Knex.QueryBuilder, + helpers: OnConflictMergeHelpers + ) => void; +} + +export class OnConflictBuilder { readonly #knex: Knex; readonly #localSchema: TLocalSchema; readonly #conflictColumns: string[]; @@ -47,9 +99,37 @@ export class OnConflictBuilder< async merge( data: InsertType, - updateData?: Partial> + options?: OnConflictMergeOptions + ): Promise; + async merge( + data: InsertType, + updateData?: OnConflictUpdateData, + options?: OnConflictMergeOptions + ): Promise; + async merge( + data: InsertType, + updateData?: + | OnConflictUpdateData + | OnConflictMergeOptions, + options?: OnConflictMergeOptions ): Promise { - return this.#execute(data, 'merge', updateData) as Promise; + let resolvedUpdateData: OnConflictUpdateData | undefined; + let resolvedOptions = options; + + if (options === undefined && isMergeOptions(updateData)) { + resolvedOptions = updateData; + } else { + resolvedUpdateData = updateData as + | OnConflictUpdateData + | undefined; + } + + return this.#execute( + data, + 'merge', + resolvedUpdateData, + resolvedOptions + ) as Promise; } async ignore(data: InsertType): Promise { @@ -59,7 +139,8 @@ export class OnConflictBuilder< async #execute( data: InsertType, mode: 'merge' | 'ignore', - updateData?: Partial> + updateData?: OnConflictUpdateData, + options?: OnConflictMergeOptions ): Promise { const tableName = getTableName(this.#localSchema); const timestamps: { createdAt: string; updatedAt: string } | null = @@ -90,13 +171,15 @@ export class OnConflictBuilder< if (mode === 'ignore') { qb = (qb as any).ignore(); } else { + const helpers = this.#createMergeHelpers(); let mergeObj: Record; if (updateData) { mergeObj = {}; for (const [key, val] of Object.entries( updateData as Record )) { - mergeObj[propToCol.get(key) ?? key] = val; + mergeObj[propToCol.get(key) ?? key] = + typeof val === 'function' ? val(helpers) : val; } } else { mergeObj = { ...mapped }; @@ -106,6 +189,7 @@ export class OnConflictBuilder< } } qb = (qb as any).merge(mergeObj); + options?.where?.(qb as unknown as Knex.QueryBuilder, helpers); } const rows = await (qb as any).returning('*'); @@ -118,6 +202,41 @@ export class OnConflictBuilder< } return result as TResult; } + + #createMergeHelpers(): OnConflictMergeHelpers { + return { + knex: this.#knex, + column: ref => this.#resolveTopLevelColumn(ref), + raw: (sql, bindings = []) => this.#knex.raw(sql, bindings as any), + excluded: ref => + this.#knex.raw('excluded.??', [ + this.#resolveTopLevelColumn(ref) + ]) + }; + } + + #resolveTopLevelColumn(ref: ColumnRef): string { + const column = resolveColumnRef( + ref as ColumnRef, + this.#localSchema as any, + 'onConflict.merge', + this.#knex + ); + if (typeof column !== 'string') { + throw new Error( + 'onConflict.merge only accepts top-level column references' + ); + } + return column; + } +} + +function isMergeOptions( + value: unknown +): value is OnConflictMergeOptions { + if (!value || typeof value !== 'object') return false; + const keys = Object.keys(value); + return keys.length > 0 && keys.every(key => key === 'where'); } // --------------------------------------------------------------------------- diff --git a/libs/knex-schema/src/orm.test.ts b/libs/knex-schema/src/orm.test.ts index 8f6492f4..5d9e00b8 100644 --- a/libs/knex-schema/src/orm.test.ts +++ b/libs/knex-schema/src/orm.test.ts @@ -1,7 +1,7 @@ // @cleverbrush/knex-schema — ORM extensions tests import Knex from 'knex'; -import { afterAll, describe, expect, it, vi } from 'vitest'; +import { afterAll, describe, expect, expectTypeOf, it, vi } from 'vitest'; import { boolean, date, @@ -14,9 +14,10 @@ import { object, query, rawQuery, - string + string, + validateEntitiesAgainstDatabase } from './index.js'; -import type { DatabaseTableState } from './types.js'; +import type { DatabaseTableState, InferDatabaseRow } from './types.js'; const knex = Knex({ client: 'pg' }); @@ -1282,6 +1283,73 @@ describe('diffSchema', () => { }); }); +describe('validateEntitiesAgainstDatabase', () => { + const SimpleSchema = object({ + id: number().primaryKey(), + name: string() + }).hasTableName('simple'); + const SimpleEntity = defineEntity(SimpleSchema); + + it('reports missing tables without mutating the database', async () => { + const fakeKnex = { + schema: { + hasTable: vi.fn().mockResolvedValue(false) + }, + raw: vi.fn() + } as any; + + const result = await validateEntitiesAgainstDatabase(fakeKnex, [ + SimpleEntity + ]); + + expect(result.valid).toBe(false); + expect(result.checkedTables).toEqual(['simple']); + expect(result.issues).toEqual([ + { type: 'missing-table', tableName: 'simple' } + ]); + expect(fakeKnex.raw).not.toHaveBeenCalled(); + }); + + it('reports schema drift from live table introspection', async () => { + const fakeKnex = { + schema: { + hasTable: vi.fn().mockResolvedValue(true) + }, + raw: vi.fn(async (sql: string) => { + if (sql.includes('information_schema.columns')) { + return { + rows: [ + { + column_name: 'id', + data_type: 'integer', + is_nullable: 'NO', + column_default: null, + character_maximum_length: null, + numeric_precision: null + } + ] + }; + } + return { rows: [] }; + }) + } as any; + + const result = await validateEntitiesAgainstDatabase(fakeKnex, [ + SimpleEntity + ]); + + expect(result.valid).toBe(false); + expect(result.issues).toHaveLength(1); + expect(result.issues[0]).toMatchObject({ + type: 'schema-drift', + tableName: 'simple' + }); + if (result.issues[0].type === 'schema-drift') { + expect(result.issues[0].diff.addColumns[0].name).toBe('name'); + } + }); +}); + // ═══════════════════════════════════════════════════════════════════════════ // Phase 5: Migration generation // ═══════════════════════════════════════════════════════════════════════════ @@ -1624,6 +1692,49 @@ describe('Phase 2 query methods', () => { expect(typeof ob.ignore).toBe('function'); }); + it('onConflict().merge() accepts raw update expressions and where options', () => { + const UserSchema = object({ + id: number().primaryKey(), + email: string().unique(), + name: string(), + updatedAt: date().hasColumnName('updated_at') + }).hasTableName('users'); + + const compileOnly = false as boolean; + if (compileOnly) { + void query(knex, UserSchema) + .onConflict(t => t.email) + .merge( + { email: 'a@example.com', name: 'Alice' }, + { + name: ({ excluded }) => excluded(t => t.name), + updatedAt: ({ knex }) => knex.fn.now() + }, + { + where: (qb, { column, excluded }) => { + qb.whereRaw('?? < ??', [ + column(t => t.updatedAt), + excluded(t => t.updatedAt) + ]); + } + } + ); + } + + const ob = query(knex, UserSchema).onConflict(t => t.email); + expect(typeof ob.merge).toBe('function'); + }); + + it('InferDatabaseRow keeps required fields and allows null optional fields', () => { + type PostRow = InferDatabaseRow; + + expectTypeOf().toMatchTypeOf<{ + id: number; + title: string; + categoryId?: number | null | undefined; + }>(); + }); + it('upsert() method is callable', () => { const UserSchema = object({ id: number().primaryKey(), diff --git a/libs/knex-schema/src/types.ts b/libs/knex-schema/src/types.ts index 260715e3..cf2cdc7e 100644 --- a/libs/knex-schema/src/types.ts +++ b/libs/knex-schema/src/types.ts @@ -193,6 +193,42 @@ export type InsertType< T extends ObjectSchemaBuilder > = InferType>; +// --------------------------------------------------------------------------- +// Database row helpers +// --------------------------------------------------------------------------- + +type OptionalKeys = { + [K in keyof T]-?: undefined extends T[K] ? K : never; +}[keyof T]; + +type RequiredKeys = Exclude>; + +/** + * Normalize a schema-inferred property type to the value shape commonly + * returned by database rows. + * + * Optional schema properties may be absent in payloads, but database rows use + * `NULL` for persisted missing values. + */ +export type InferDatabaseValue = undefined extends T + ? Exclude | null | undefined + : T; + +/** + * Infer the object shape of a persisted database row from an object schema. + * + * This is useful for mapper code and raw-query helpers where `InferType` + * is too strict because optional schema properties can come back as `null` + * from SQL. + */ +export type InferDatabaseRow< + T extends ObjectSchemaBuilder +> = { + [K in RequiredKeys>]: InferDatabaseValue[K]>; +} & { + [K in OptionalKeys>]?: InferDatabaseValue[K]>; +}; + // --------------------------------------------------------------------------- // Primary-key type helpers (driven by PRIMARY_KEY_BRAND / COMPOSITE_PRIMARY_KEY_BRAND) // --------------------------------------------------------------------------- diff --git a/libs/orm-cli/README.md b/libs/orm-cli/README.md index 6bfc3ba2..3a82bf41 100644 --- a/libs/orm-cli/README.md +++ b/libs/orm-cli/README.md @@ -8,6 +8,7 @@ cb-orm migrate generate [name] # diff DB → schema, emit TS migration file (n cb-orm migrate run # apply pending migrations cb-orm migrate rollback # roll back last batch cb-orm migrate status # list applied/pending migrations +cb-orm validate # check entity schemas against the live DB (read-only) cb-orm db push # sync schema in-place (dev only) ``` @@ -57,6 +58,7 @@ export default defineConfig({ "db:run": "cb-orm migrate run", "db:rollback": "cb-orm migrate rollback", "db:status": "cb-orm migrate status", + "db:validate": "cb-orm validate", "db:push": "cb-orm db push" } } @@ -122,6 +124,19 @@ npx cb-orm migrate status # ○ 20260423120000_add_role_column.ts ``` +### `validate` + +Checks every configured entity against the live database without applying +changes, writing migration files, or updating snapshots. It exits with status +`1` when a table is missing or a schema diff is detected. + +```sh +npx cb-orm validate +# Schema is in sync (4 table(s) checked). +``` + +Use this in CI before deploys when you want to fail fast on schema drift. + ### `db push` Applies all schema changes directly to the database **without** writing a @@ -160,6 +175,7 @@ The CLI delegates all schema intelligence to `@cleverbrush/knex-schema`: | Diff schema vs DB | `diffSchema(schema, dbState)` | | Generate ALTER TABLE source | `generateMigration(diff, tableName)` | | Apply diff without file | `applyDiff(knex, diff, tableName)` | +| Validate without changes | `validateEntitiesAgainstDatabase(knex, entities)` | | Polymorphic variant tables | `getPolymorphicVariantSchemas(schema)` | `tsx` is used to load `db.config.ts` at runtime by registering the diff --git a/libs/orm-cli/src/cli.test.ts b/libs/orm-cli/src/cli.test.ts index 00c4925a..768ae8e7 100644 --- a/libs/orm-cli/src/cli.test.ts +++ b/libs/orm-cli/src/cli.test.ts @@ -8,7 +8,15 @@ import { mkdirSync, readdirSync, readFileSync, rmSync } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { + afterAll, + beforeAll, + beforeEach, + describe, + expect, + it, + vi +} from 'vitest'; import { parseFlags } from './cli.js'; @@ -19,6 +27,7 @@ import { parseFlags } from './cli.js'; const _mockGenerateMigrationsForContext = vi.fn(); const _mockLoadSnapshot = vi.fn(); const _mockWriteSnapshot = vi.fn(); +const _mockValidateEntitiesAgainstDatabase = vi.fn(); vi.mock('@cleverbrush/knex-schema', async importOriginal => { const actual = @@ -28,6 +37,7 @@ vi.mock('@cleverbrush/knex-schema', async importOriginal => { generateMigrationsForContext: _mockGenerateMigrationsForContext, loadSnapshot: _mockLoadSnapshot, writeSnapshot: _mockWriteSnapshot, + validateEntitiesAgainstDatabase: _mockValidateEntitiesAgainstDatabase, getPolymorphicVariantSchemas: vi.fn().mockReturnValue([]), getTableName: vi.fn().mockReturnValue('users'), tableExistsInDb: vi.fn().mockResolvedValue(false), @@ -52,6 +62,10 @@ vi.mock('@cleverbrush/knex-schema', async importOriginal => { }; }); +beforeEach(() => { + _mockValidateEntitiesAgainstDatabase.mockReset(); +}); + // ═══════════════════════════════════════════════════════════════════════════ describe('parseFlags', () => { it('parses boolean flags', () => { @@ -198,6 +212,85 @@ describe('push command — production guard', () => { }); }); +// ═══════════════════════════════════════════════════════════════════════════ +describe('validate command', () => { + it('routes cb-orm validate with --config and destroys the knex pool', async () => { + const destroy = vi.fn().mockResolvedValue(undefined); + const fakeKnex = { schema: {}, destroy }; + _mockValidateEntitiesAgainstDatabase.mockResolvedValueOnce({ + valid: true, + checkedTables: ['users'], + issues: [] + }); + + const tmp = path.join(os.tmpdir(), `orm-cli-validate-${Date.now()}`); + mkdirSync(tmp, { recursive: true }); + const cfgPath = path.join(tmp, 'db.config.mjs'); + const stash = (globalThis as any).__cbOrmFakeKnex; + (globalThis as any).__cbOrmFakeKnex = fakeKnex; + const log = vi.spyOn(console, 'log').mockImplementation(() => {}); + + try { + const fs = await import('node:fs'); + fs.writeFileSync( + cfgPath, + ` + export default { + knex: globalThis.__cbOrmFakeKnex, + entities: {}, + migrations: { directory: ${JSON.stringify(tmp)} } + }; + `, + 'utf-8' + ); + + const { run } = await import('./cli.js'); + await run(['validate', '--config', cfgPath]); + + expect(_mockValidateEntitiesAgainstDatabase).toHaveBeenCalledWith( + fakeKnex, + [] + ); + expect(log.mock.calls.flat().join(' ')).toMatch( + /Schema is in sync/ + ); + expect(destroy).toHaveBeenCalledTimes(1); + } finally { + log.mockRestore(); + (globalThis as any).__cbOrmFakeKnex = stash; + rmSync(tmp, { recursive: true, force: true }); + } + }); + + it('exits 1 when schema drift is detected', async () => { + _mockValidateEntitiesAgainstDatabase.mockResolvedValueOnce({ + valid: false, + checkedTables: ['users'], + issues: [{ type: 'missing-table', tableName: 'users' }] + }); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => { + throw new Error('process.exit called'); + }) as any); + const errSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); + + const { validate } = await import('./commands/validate.js'); + const config = { + knex: {} as any, + entities: {}, + migrations: { directory: './migrations' } + }; + + await expect(validate(config)).rejects.toThrow('process.exit called'); + expect(exitSpy).toHaveBeenCalledWith(1); + expect(errSpy.mock.calls.flat().join('\n')).toMatch( + /Missing table: users/ + ); + + exitSpy.mockRestore(); + errSpy.mockRestore(); + }); +}); + // ═══════════════════════════════════════════════════════════════════════════ // Regression: the CLI must close the knex pool after running a command so // the host process can exit promptly (knex pools otherwise keep idle TCP diff --git a/libs/orm-cli/src/cli.ts b/libs/orm-cli/src/cli.ts index 161cedd8..6519947e 100644 --- a/libs/orm-cli/src/cli.ts +++ b/libs/orm-cli/src/cli.ts @@ -21,7 +21,8 @@ export async function run(argv: string[]): Promise { return; } - const flags = parseFlags(rest); + const flagArgs = sub?.startsWith('--') ? [sub, ...rest] : rest; + const flags = parseFlags(flagArgs); const configPath = flags['--config'] as string | undefined; // Track the loaded config so we can always destroy the knex pool — even @@ -30,7 +31,11 @@ export async function run(argv: string[]): Promise { let loadedConfig: OrmCliConfig | undefined; try { - if (cmd === 'migrate') { + if (cmd === 'validate') { + loadedConfig = await loadConfig(configPath); + const { validate } = await import('./commands/validate.js'); + await validate(loadedConfig); + } else if (cmd === 'migrate') { switch (sub) { case 'generate': { // Collect positional args, skipping flag names and their @@ -159,6 +164,7 @@ COMMANDS migrate run Apply pending migrations (knex.migrate.latest) migrate rollback Roll back last batch (knex.migrate.rollback) migrate status List applied and pending migrations + validate Check entity schemas against the live DB (read-only) db push Sync schema to DB in-place (dev only — no migration file) OPTIONS diff --git a/libs/orm-cli/src/commands/validate.ts b/libs/orm-cli/src/commands/validate.ts new file mode 100644 index 00000000..1d2047af --- /dev/null +++ b/libs/orm-cli/src/commands/validate.ts @@ -0,0 +1,83 @@ +// @cleverbrush/orm-cli — validate command +// +// Checks configured entities against the live database without applying +// changes or writing migration files. + +import type { MigrationDiff } from '@cleverbrush/knex-schema'; +import { validateEntitiesAgainstDatabase } from '@cleverbrush/knex-schema'; +import type { OrmCliConfig } from '../types.js'; + +/** + * Validate every configured entity against the live database. + * + * Exits with status 1 when a table is missing or schema drift is detected. + */ +export async function validate(config: OrmCliConfig): Promise { + const result = await validateEntitiesAgainstDatabase( + config.knex, + Object.values(config.entities) + ); + + if (result.valid) { + console.log( + `Schema is in sync (${result.checkedTables.length} table(s) checked).` + ); + return; + } + + console.error('Schema drift detected:'); + for (const issue of result.issues) { + if (issue.type === 'missing-table') { + console.error(` - Missing table: ${issue.tableName}`); + } else { + console.error( + ` - Drift in ${issue.tableName}: ${summarizeDiff(issue.diff)}` + ); + } + } + + process.exit(1); +} + +function summarizeDiff(diff: MigrationDiff): string { + const parts = [ + formatCount(diff.addColumns.length, 'column to add', 'columns to add'), + formatCount( + diff.dropColumns.length, + 'column to drop', + 'columns to drop' + ), + formatCount( + diff.alterColumns.length, + 'column to alter', + 'columns to alter' + ), + formatCount(diff.addIndexes.length, 'index to add', 'indexes to add'), + formatCount( + diff.dropIndexes.length, + 'index to drop', + 'indexes to drop' + ), + formatCount( + diff.addForeignKeys.length, + 'foreign key to add', + 'foreign keys to add' + ), + formatCount( + diff.dropForeignKeys.length, + 'foreign key to drop', + 'foreign keys to drop' + ) + ].filter((part): part is string => part !== null); + + return parts.length > 0 ? parts.join(', ') : 'unknown drift'; +} + +function formatCount( + count: number, + singular: string, + plural: string +): string | null { + if (count === 0) return null; + return `${count} ${count === 1 ? singular : plural}`; +} diff --git a/libs/orm/README.md b/libs/orm/README.md index 23021620..568115e2 100644 --- a/libs/orm/README.md +++ b/libs/orm/README.md @@ -337,6 +337,9 @@ npx cb-orm migrate generate add_users_table # Apply pending migrations npx cb-orm migrate run + +# Read-only CI check for schema drift +npx cb-orm validate ``` --- diff --git a/libs/server/README.md b/libs/server/README.md index 6351892f..481328f2 100644 --- a/libs/server/README.md +++ b/libs/server/README.md @@ -8,8 +8,8 @@ A schema-first HTTP server framework for Node.js. Combines [`@cleverbrush/schema ## Features - **Fluent endpoint builder** — `endpoint.get('/users').body(schema).query(schema).authorize()` with fully typed handler context. -- **Action results** — `ActionResult.ok()`, `.created()`, `.noContent()`, `.redirect()`, `.file()`, `.stream()`, `.status()` — no manual `res.write()` / `res.end()`. -- **Content negotiation** — pluggable `ContentTypeHandler` registry; JSON registered by default; honours the `Accept` request header. +- **Action results** — `ActionResult.ok()`, `.created()`, `.noContent()`, `.redirect()`, `.file()`, `.stream()`, `.raw()`, `.status()` — no manual `res.write()` / `res.end()` unless you explicitly opt in. +- **Content negotiation** — pluggable `ContentTypeHandler` registry; JSON and `application/x-www-form-urlencoded` registered by default; honours the `Accept` request header. - **Middleware pipeline** — `server.use(middleware)` for global middleware; per-endpoint middleware via `handle(ep, handler, { middlewares })`. - **DI integration** — `endpoint.inject({ db: IDbContext })` resolves services per-request from a `@cleverbrush/di` container. - **Authentication & authorization** — `server.useAuthentication()` / `server.useAuthorization()` wired to `@cleverbrush/auth` schemes and policies. @@ -183,8 +183,30 @@ await server.listen(3000); | `ActionResult.file(buffer, fileName)` | 200 | Attachment download | | `ActionResult.content(body, contentType)` | 200 | Arbitrary string body | | `ActionResult.stream(readable, contentType)` | 200 | Pipes a `Readable` | +| `ActionResult.raw(handler)` | custom | Native Node `req` / `res` escape hatch | | `ActionResult.status(status)` | any | Bare status, no body | +Use `ActionResult.raw()` when integrating a library that already writes to +Node's `http.ServerResponse`: + +```ts +server.handle(WebhookEndpoint, () => + ActionResult.raw(async (req, res) => { + await thirdPartyWebhookHandler(req, res); + }) +); +``` + +## URL-Encoded Bodies + +`application/x-www-form-urlencoded` request bodies are parsed by default and +validated against the endpoint body schema. Repeated fields become arrays: + +```ts +// tag=work&tag=travel&title=Trip +// → { tag: ['work', 'travel'], title: 'Trip' } +``` + ## File Upload Accept file uploads via `multipart/form-data` by chaining `.upload()` on an endpoint: @@ -277,6 +299,17 @@ server.useAuthentication({ server.useAuthorization(); ``` +By default, only `defaultScheme` is attempted. Use `trySchemes` when an app +accepts multiple credential types on the same endpoints: + +```ts +server.useAuthentication({ + defaultScheme: 'cookie', + schemes: [cookieScheme(options), jwtScheme(options)], + trySchemes: ['cookie', 'jwt'] // or 'all' +}); +``` + ## HTTP Errors Throw any `HttpError` subclass from a handler — it becomes a Problem Details response automatically: diff --git a/libs/server/src/ActionResult.ts b/libs/server/src/ActionResult.ts index 7c29f00e..aa33351a 100644 --- a/libs/server/src/ActionResult.ts +++ b/libs/server/src/ActionResult.ts @@ -6,6 +6,17 @@ import type { ContentNegotiator } from './ContentNegotiator.js'; // Base // --------------------------------------------------------------------------- +/** + * Handler used by {@link ActionResult.raw}. + * + * The handler receives the native Node request and response and is responsible + * for writing headers/body as needed. + */ +export type RawResultHandler = ( + req: http.IncomingMessage, + res: http.ServerResponse +) => void | Promise; + /** * Abstract base for all HTTP action results. * @@ -159,6 +170,18 @@ export abstract class ActionResult { return new StreamResult(readable, contentType, fileName); } + /** + * Execute a raw Node request/response handler. + * + * Use this for integrations that already know how to write to + * `http.ServerResponse`, such as webhook libraries or compatibility + * adapters. The callback is responsible for ending the response when it + * writes a body. + */ + static raw(handler: RawResultHandler): RawResult { + return new RawResult(handler); + } + /** Bare status code with no body. */ static status( status: S, @@ -168,6 +191,31 @@ export abstract class ActionResult { } } +// --------------------------------------------------------------------------- +// RawResult +// --------------------------------------------------------------------------- + +/** + * Runs a native Node request/response callback. + * Created by `ActionResult.raw()`. + */ +export class RawResult extends ActionResult { + readonly handler: RawResultHandler; + + constructor(handler: RawResultHandler) { + super(); + this.handler = handler; + } + + async executeAsync( + req: http.IncomingMessage, + res: http.ServerResponse, + _contentNegotiator: ContentNegotiator + ): Promise { + await this.handler(req, res); + } +} + // --------------------------------------------------------------------------- // JsonResult // --------------------------------------------------------------------------- diff --git a/libs/server/src/ContentNegotiator.security.test.ts b/libs/server/src/ContentNegotiator.security.test.ts index a8563111..51ba8380 100644 --- a/libs/server/src/ContentNegotiator.security.test.ts +++ b/libs/server/src/ContentNegotiator.security.test.ts @@ -49,4 +49,44 @@ describe('ContentNegotiator — security', () => { expect(handler.deserialize(json)).toBeDefined(); }); + + it('URL-encoded deserializer is registered by default', () => { + const cn = new ContentNegotiator(); + const handler = cn.selectRequestHandler( + 'application/x-www-form-urlencoded; charset=utf-8' + ); + + expect(handler).toBeTruthy(); + expect(handler?.deserialize('name=Jane&enabled=true')).toEqual({ + name: 'Jane', + enabled: 'true' + }); + }); + + it('URL-encoded deserializer preserves repeated fields as arrays', () => { + const cn = new ContentNegotiator(); + const handler = cn.selectRequestHandler( + 'application/x-www-form-urlencoded' + )!; + + expect(handler.deserialize('tag=work&tag=travel&single=one')).toEqual({ + tag: ['work', 'travel'], + single: 'one' + }); + }); + + it('URL-encoded deserializer skips prototype pollution keys', () => { + const cn = new ContentNegotiator(); + const handler = cn.selectRequestHandler( + 'application/x-www-form-urlencoded' + )!; + const result = handler.deserialize( + '__proto__=polluted&constructor=x&prototype=y&safe=ok' + ) as Record; + + expect(result.safe).toBe('ok'); + expect(result.__proto__).toBe(Object.prototype); + expect(result.constructor).toBe(Object); + expect(({} as any).polluted).toBeUndefined(); + }); }); diff --git a/libs/server/src/ContentNegotiator.ts b/libs/server/src/ContentNegotiator.ts index 9b2ce62c..b61e5bdb 100644 --- a/libs/server/src/ContentNegotiator.ts +++ b/libs/server/src/ContentNegotiator.ts @@ -1,7 +1,10 @@ import { checkJsonDepth, safeJsonParse } from './safeJson.js'; import type { ContentTypeHandler } from './types.js'; -const JSON_HANDLER: ContentTypeHandler = { +/** + * Built-in JSON content type handler. + */ +export const jsonContentTypeHandler: ContentTypeHandler = { mimeType: 'application/json', serialize(value: unknown): string { return JSON.stringify(value); @@ -13,6 +16,57 @@ const JSON_HANDLER: ContentTypeHandler = { } }; +/** + * Built-in `application/x-www-form-urlencoded` content type handler. + * + * Repeated fields are deserialized as arrays in insertion order: + * `tag=a&tag=b` becomes `{ tag: ['a', 'b'] }`. + */ +export const formUrlEncodedContentTypeHandler: ContentTypeHandler = { + mimeType: 'application/x-www-form-urlencoded', + serialize(value: unknown): string { + const params = new URLSearchParams(); + if (value && typeof value === 'object') { + for (const [key, item] of Object.entries( + value as Record + )) { + if (item === undefined || item === null) continue; + if (Array.isArray(item)) { + for (const nested of item) { + params.append(key, String(nested)); + } + } else { + params.append(key, String(item)); + } + } + } + return params.toString(); + }, + deserialize(raw: string): unknown { + const result: Record = {}; + const params = new URLSearchParams(raw); + + for (const [key, value] of params) { + if (isUnsafeFormKey(key)) continue; + + const existing = result[key]; + if (existing === undefined) { + result[key] = value; + } else if (Array.isArray(existing)) { + existing.push(value); + } else { + result[key] = [existing, value]; + } + } + + return result; + } +}; + +function isUnsafeFormKey(key: string): boolean { + return key === '__proto__' || key === 'constructor' || key === 'prototype'; +} + interface ParsedAccept { mimeType: string; quality: number; @@ -48,7 +102,8 @@ export class ContentNegotiator { readonly #handlers: Map = new Map(); constructor() { - this.register(JSON_HANDLER); + this.register(jsonContentTypeHandler); + this.register(formUrlEncodedContentTypeHandler); } /** diff --git a/libs/server/src/Endpoint.ts b/libs/server/src/Endpoint.ts index e2bcdfb2..0d00b238 100644 --- a/libs/server/src/Endpoint.ts +++ b/libs/server/src/Endpoint.ts @@ -13,6 +13,7 @@ import type { FileResult, JsonResult, NoContentResult, + RawResult, RedirectResult, StatusCodeResult, StreamResult @@ -178,8 +179,8 @@ type HasResponses = keyof ResponsesOf extends never ? false : true; * - Non-null schema for code 200 → also allows a plain object (treated as 200 by the server) * - Non-null schema for other codes → `JsonResult` * - * `FileResult`, `StreamResult`, `ContentResult`, and `RedirectResult` are always - * permitted as an escape hatch for non-JSON responses. + * `FileResult`, `StreamResult`, `ContentResult`, `RedirectResult`, and + * `RawResult` are always permitted as escape hatches for non-JSON responses. */ export type AllowedResponseReturn> = | { @@ -197,7 +198,8 @@ export type AllowedResponseReturn> = | FileResult | StreamResult | ContentResult - | RedirectResult; + | RedirectResult + | RawResult; // --------------------------------------------------------------------------- // Handler — the action function type, inferred from an endpoint diff --git a/libs/server/src/Server.public.test.ts b/libs/server/src/Server.public.test.ts index 420da13a..c25fdbab 100644 --- a/libs/server/src/Server.public.test.ts +++ b/libs/server/src/Server.public.test.ts @@ -1,8 +1,12 @@ import { IncomingMessage, ServerResponse } from 'node:http'; import { Socket } from 'node:net'; +import type { AuthenticationScheme } from '@cleverbrush/auth'; +import { Principal } from '@cleverbrush/auth'; import { describe, expect, it } from 'vitest'; +import { ActionResult } from './ActionResult.js'; import { endpoint } from './Endpoint.js'; import { RequestContext } from './RequestContext.js'; +import { ServerBuilder } from './Server.js'; describe('Public endpoint integration', () => { it('public endpoint (authRoles=null) is accessible without authentication', () => { @@ -75,4 +79,78 @@ describe('Public endpoint integration', () => { const meta = ctx.items.get('__endpoint_meta') as any; expect(meta.authRoles).toBeNull(); }); + + it('tries configured authentication schemes in order', async () => { + const first: AuthenticationScheme = { + name: 'cookie', + authenticate: async () => ({ succeeded: false }) + }; + const second: AuthenticationScheme<{ id: string }> = { + name: 'bearer', + authenticate: async authCtx => { + if (authCtx.headers.authorization !== 'Bearer token') { + return { succeeded: false }; + } + return { + succeeded: true, + principal: new Principal(true, { id: 'user-1' }) + }; + } + }; + + const server = await new ServerBuilder() + .useAuthentication({ + defaultScheme: 'cookie', + schemes: [first, second], + trySchemes: ['cookie', 'bearer'] + }) + .handle(endpoint.get('/api/me').authorize(), ({ context }) => ({ + principal: context.principal + })) + .listen(0, '127.0.0.1'); + + try { + const port = server.address?.port; + expect(port).toBeTypeOf('number'); + + const response = await fetch(`http://127.0.0.1:${port}/api/me`, { + headers: { authorization: 'Bearer token' } + }); + + expect(response.status).toBe(200); + expect(await response.json()).toEqual({ + principal: { + isAuthenticated: true, + value: { id: 'user-1' }, + claims: {} + } + }); + } finally { + await server.close(); + } + }); + + it('writes native responses from ActionResult.raw()', async () => { + const server = await new ServerBuilder() + .handle(endpoint.get('/api/raw'), () => + ActionResult.raw((_req, res) => { + res.writeHead(202, { 'content-type': 'text/plain' }); + res.end('raw-body'); + }) + ) + .listen(0, '127.0.0.1'); + + try { + const port = server.address?.port; + expect(port).toBeTypeOf('number'); + + const response = await fetch(`http://127.0.0.1:${port}/api/raw`); + + expect(response.status).toBe(202); + expect(response.headers.get('content-type')).toBe('text/plain'); + expect(await response.text()).toBe('raw-body'); + } finally { + await server.close(); + } + }); }); diff --git a/libs/server/src/Server.ts b/libs/server/src/Server.ts index 9c4aa8f4..28f557af 100644 --- a/libs/server/src/Server.ts +++ b/libs/server/src/Server.ts @@ -72,6 +72,14 @@ export interface AuthenticationConfig { defaultScheme: string; /** Registered authentication schemes. */ schemes: AuthenticationScheme[]; + /** + * Optional ordered authentication fallback. + * + * By default, only `defaultScheme` is attempted. Set to `'all'` to try + * every registered scheme in registration order, or pass scheme names to + * try a specific ordered subset. + */ + trySchemes?: 'all' | readonly string[]; } /** @@ -1384,6 +1392,22 @@ function createAuthenticationMiddleware( schemeMap.set(scheme.name, scheme); } + let schemesToTry: AuthenticationScheme[]; + if (config.trySchemes === 'all') { + schemesToTry = config.schemes; + } else { + const schemeNames: readonly string[] = + config.trySchemes && config.trySchemes.length > 0 + ? config.trySchemes + : [config.defaultScheme]; + schemesToTry = schemeNames + .map(name => schemeMap.get(name)) + .filter( + (scheme): scheme is AuthenticationScheme => + scheme !== undefined + ); + } + return async (ctx, next) => { // Skip authentication for public endpoints (authRoles === null) const epMeta = ctx.items.get('__endpoint_meta') as @@ -1396,14 +1420,6 @@ function createAuthenticationMiddleware( return; } - const scheme = schemeMap.get(config.defaultScheme); - if (!scheme) { - // No matching scheme — leave principal as anonymous - ctx.principal = Principal.anonymous(); - await next(); - return; - } - // Build transport-agnostic auth context const authCtx: AuthenticationContext = { headers: ctx.headers, @@ -1411,12 +1427,13 @@ function createAuthenticationMiddleware( items: ctx.items }; - const result = await scheme.authenticate(authCtx); - - if (result.succeeded) { - ctx.principal = result.principal; - } else { - ctx.principal = Principal.anonymous(); + ctx.principal = Principal.anonymous(); + for (const scheme of schemesToTry) { + const result = await scheme.authenticate(authCtx); + if (result.succeeded) { + ctx.principal = result.principal; + break; + } } await next(); diff --git a/libs/server/src/index.ts b/libs/server/src/index.ts index 4124fcb8..1869b4bb 100644 --- a/libs/server/src/index.ts +++ b/libs/server/src/index.ts @@ -4,6 +4,8 @@ export { FileResult, JsonResult, NoContentResult, + RawResult, + type RawResultHandler, RedirectResult, StatusCodeResult, StreamResult @@ -15,6 +17,11 @@ export { createCacheTagTree, serializeTag } from './CacheTag.js'; +export { + ContentNegotiator, + formUrlEncodedContentTypeHandler, + jsonContentTypeHandler +} from './ContentNegotiator.js'; export { type ApiContract, type ApiGroup, diff --git a/websites/docs/app/client/sections/cacheTags.tsx b/websites/docs/app/client/sections/cacheTags.tsx index 8a8b783f..fdf9cdc8 100644 --- a/websites/docs/app/client/sections/cacheTags.tsx +++ b/websites/docs/app/client/sections/cacheTags.tsx @@ -48,7 +48,7 @@ await client.todos.list({ query: { page: 1 } }); const ListTodos = todosResource .get() .query(TodoListQuerySchema) - .clearsCacheTag('todo-list', p => ({ + .cacheTag('todo-list', p => ({ page: p.query.page, limit: p.query.limit })) @@ -68,6 +68,36 @@ const UpdateTodo = todosResource +
+

External Cache Invalidation

+

+ Use externalCacheTags() to send endpoint{' '} + .clearsCacheTag() metadata to any external + cache system after successful mutations. +

+
+                     externalCache.invalidate(tag),
+        }),
+    ],
+});
+
+// Next.js example:
+import { revalidateTag } from 'next/cache';
+externalCacheTags({ invalidateTag: revalidateTag });
+`)
+                        }}
+                    />
+                
+
+

How It Works

    diff --git a/websites/docs/app/env/page.tsx b/websites/docs/app/env/page.tsx index 80bfa91a..e90374dd 100644 --- a/websites/docs/app/env/page.tsx +++ b/websites/docs/app/env/page.tsx @@ -98,8 +98,8 @@ export default function EnvPage() {
                             
    @@ -166,6 +166,34 @@ parseEnv({
                         
+ {/* ── Booleans ─────────────────────────────────────── */} +
+

Environment Booleans

+

+ Use envBoolean() for shell-style flags. It + accepts true / false,{' '} + 1 / 0, yes /{' '} + no, and on / off{' '} + by default. +

+
+                        
+                    
+
+ {/* ── Flat Mode ────────────────────────────────────── */}

Flat Mode

diff --git a/websites/docs/app/knex-schema/page.tsx b/websites/docs/app/knex-schema/page.tsx index 848e31b4..04668be9 100644 --- a/websites/docs/app/knex-schema/page.tsx +++ b/websites/docs/app/knex-schema/page.tsx @@ -160,6 +160,68 @@ const user = await query(db, UserSchema)
+
+

Guarded Upserts

+

+ Use onConflict().merge() when an import or + sync job should insert missing rows, but update existing + rows only when the incoming data is newer. +

+
+                         t.sku)
+    .merge(
+        {
+            sku: 'SKU-123',
+            price: 1999,
+            sourceUpdatedAt: new Date('2026-01-15T10:00:00Z'),
+        },
+        {
+            price: ({ excluded }) => excluded(t => t.price),
+            sourceUpdatedAt: ({ excluded }) =>
+                excluded(t => t.sourceUpdatedAt),
+            syncedAt: ({ knex }) => knex.fn.now(),
+        },
+        {
+            where: (qb, { column }) => {
+                qb.whereRaw('?? < excluded.??', [
+                    column(t => t.sourceUpdatedAt),
+                    column(t => t.sourceUpdatedAt),
+                ]);
+            },
+        }
+    );
+
+import type { InferDatabaseRow } from '@cleverbrush/knex-schema';
+type ProductRow = InferDatabaseRow;`)
+                            }}
+                        />
+                    
+

+ excluded() references the incoming row from + the failed insert, column() resolves schema + property names to SQL column names, and the{' '} + where callback adds a PostgreSQL guarded + update. +

+
+                        
+                    
+
+ {/* ── Eager Loading ────────────────────────────────── */}

Eager Loading (No N+1)

diff --git a/websites/docs/app/orm/page.tsx b/websites/docs/app/orm/page.tsx index 8e1be830..5ae24a9f 100644 --- a/websites/docs/app/orm/page.tsx +++ b/websites/docs/app/orm/page.tsx @@ -330,6 +330,9 @@ npx cb-orm migrate run # Check migration status npx cb-orm migrate status +# Read-only schema drift check +npx cb-orm validate + # Sync in-place (dev only — no migration file) npx cb-orm db push` }} diff --git a/websites/docs/app/server/page.tsx b/websites/docs/app/server/page.tsx index 23a903c5..554c7a82 100644 --- a/websites/docs/app/server/page.tsx +++ b/websites/docs/app/server/page.tsx @@ -208,6 +208,11 @@ return ActionResult.redirect('/login'); // File download return ActionResult.file(pdfBuffer, 'report.pdf', 'application/pdf'); +// Native Node req/res integration +return ActionResult.raw(async (req, res) => { + await webhookHandler(req, res); +}); + // Bare status code return ActionResult.status(202);`) }} @@ -215,6 +220,28 @@ return ActionResult.status(202);`)
+ {/* ── URL Encoded ────────────────────────────────── */} +
+

URL-Encoded Bodies

+

+ application/x-www-form-urlencoded request + bodies are parsed by default and validated against the + endpoint body schema. Repeated fields become arrays. +

+
+                        
+                    
+
+ {/* ── File Upload ────────────────────────────────── */}

File Upload

@@ -440,7 +467,9 @@ server role: claims.role as string }) }) - ] + ], + // Optional: try multiple registered schemes in order. + trySchemes: 'all' }) .useAuthorization();