From 9525fac1d2a09dc78be7f89315c618eef414eee8 Mon Sep 17 00:00:00 2001 From: Sam Goodwin Date: Fri, 18 Sep 2026 00:06:35 -0700 Subject: [PATCH] fix(postgres): preserve native contract query inference Signed-off-by: Sam Goodwin --- .../2-sql/2-authoring/contract-ts/README.md | 1 + .../contract-ts/src/aggregate-types.ts | 81 ++++++++ .../src/composed-authoring-helpers.ts | 32 ++- .../contract-ts/src/contract-builder.ts | 5 +- .../contract-ts/src/contract-dsl.ts | 43 +++- .../contract-ts/src/contract-types.ts | 194 ++++++++++++------ packages/3-extensions/postgres/package.json | 2 +- .../postgres/src/contract/define-contract.ts | 36 +++- .../contract-builder/native-client.test-d.ts | 168 +++++++++++++++ .../postgres/tsconfig.native-contract.json | 7 + .../3-targets/postgres/src/core/aggregates.ts | 67 +++--- .../postgres/src/core/codec-type-map.ts | 2 + .../postgres/src/core/descriptor-meta.ts | 2 + 13 files changed, 533 insertions(+), 107 deletions(-) create mode 100644 packages/2-sql/2-authoring/contract-ts/src/aggregate-types.ts create mode 100644 packages/3-extensions/postgres/test/contract-builder/native-client.test-d.ts create mode 100644 packages/3-extensions/postgres/tsconfig.native-contract.json diff --git a/packages/2-sql/2-authoring/contract-ts/README.md b/packages/2-sql/2-authoring/contract-ts/README.md index 0a36a483e00f..5ac1b265753c 100644 --- a/packages/2-sql/2-authoring/contract-ts/README.md +++ b/packages/2-sql/2-authoring/contract-ts/README.md @@ -37,6 +37,7 @@ This is the current SQL TypeScript authoring implementation. Shared descriptor t - **Composed helper namespaces**: `defineContract(config, (helpers) => ...)` synthesizes `helpers.field.*` and `helpers.type.*` from the selected family, target, and extension packs - **SQL resolution and contract generation**: internal resolution normalizes names, relations, indexes, and FK materialization before producing the canonical SQL contract artifacts - **Shared descriptor layer**: `@internal/contract-authoring` provides the target-neutral descriptor types used by the DSL and by authoring-adjacent packs +- **Native query inference**: `SqlContractResult` preserves scalar/list channels, relation cardinality, and literal model namespaces for clients consuming the contract directly. Packs can carry literal aggregate descriptors through `__aggregateDescriptors`, alongside `__codecTypes`; aggregate type maps resolve exact-codec overloads before trait fallbacks without duplicating the runtime result matrix. Contributor-facing lowering notes and detailed warning semantics live in [DEVELOPING.md](./DEVELOPING.md). diff --git a/packages/2-sql/2-authoring/contract-ts/src/aggregate-types.ts b/packages/2-sql/2-authoring/contract-ts/src/aggregate-types.ts new file mode 100644 index 000000000000..f69ec424fde0 --- /dev/null +++ b/packages/2-sql/2-authoring/contract-ts/src/aggregate-types.ts @@ -0,0 +1,81 @@ +import type { AggregateDescriptor } from '@internal/framework-components/components'; + +type PackDescriptors = Pack extends { + readonly __aggregateDescriptors?: ReadonlyArray; +} + ? Descriptor + : never; + +type Descriptors = { + [Key in keyof Packs]: PackDescriptors; +}[keyof Packs]; + +type CodecTraits = Codec extends { readonly traits: infer Traits } ? Traits : never; + +type ExactMatch = Descriptor extends { + readonly input: { readonly kind: 'codec'; readonly codecId: infer CodecId }; +} + ? Id extends CodecId + ? Descriptor + : never + : never; + +type TraitMatch = Descriptor extends { + readonly input: { readonly kind: 'trait'; readonly trait: infer Trait }; +} + ? Trait extends Traits + ? Descriptor + : never + : never; + +type Fallback = [First] extends [never] ? Second : First; + +type Match = Fallback< + ExactMatch, + TraitMatch> +>; + +type Result = Descriptor extends { + readonly output: infer Output; + readonly nullable: infer Nullable extends boolean; +} + ? { + readonly output: Output extends { + readonly kind: 'codec'; + readonly codecId: infer CodecId extends string; + } + ? CodecId + : Id & string; + readonly nullable: Nullable; + } + : never; + +type Operation = { + readonly byCodec: { + readonly [Id in keyof Codecs & string as [Match] extends [never] + ? never + : Id]: Result, Id>; + }; +} & ([Extract] extends [never] + ? Record + : { + readonly withoutInput: Result< + Extract, + never + >; + }) & + ([Extract] extends [never] + ? Record + : { + readonly anyInput: Result< + Extract, + never + >; + }); + +export type AggregateTypesFromPacks = { + readonly [Name in Descriptors['operation']]: Operation< + Extract, { readonly operation: Name }>, + Codecs + >; +}; diff --git a/packages/2-sql/2-authoring/contract-ts/src/composed-authoring-helpers.ts b/packages/2-sql/2-authoring/contract-ts/src/composed-authoring-helpers.ts index 563fa9c78ee5..7b6518365105 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/composed-authoring-helpers.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/composed-authoring-helpers.ts @@ -115,18 +115,42 @@ type PackAwareModel = { const ModelName extends string, Fields extends Record, Relations extends Record = Record, + const TNamespace extends string | undefined = undefined, >( modelName: ModelName, - input: { readonly fields: Fields; readonly relations?: Relations; readonly namespace?: string }, - ): ContractModelBuilder; + input: { + readonly fields: Fields; + readonly relations?: Relations; + readonly namespace?: TNamespace; + }, + ): ContractModelBuilder< + ModelName, + Fields, + Relations, + undefined, + undefined, + IndexTypes, + '', + NoInfer + >; < Fields extends Record, Relations extends Record = Record, + const TNamespace extends string | undefined = undefined, >(input: { readonly fields: Fields; readonly relations?: Relations; - readonly namespace?: string; - }): ContractModelBuilder; + readonly namespace?: TNamespace; + }): ContractModelBuilder< + undefined, + Fields, + Relations, + undefined, + undefined, + IndexTypes, + '', + NoInfer + >; }; export type ComposedAuthoringHelpers< diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts index 3a9d746a4d21..58090311b62d 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-builder.ts @@ -1,4 +1,4 @@ -import type { ControlPolicy } from '@internal/contract/types'; +import type { Contract, ControlPolicy } from '@internal/contract/types'; import type { ForeignKeyDefaultsState } from '@internal/contract-authoring'; import type { CodecLookup } from '@internal/framework-components/codec'; import type { @@ -10,6 +10,7 @@ import type { PackEntityHandle } from '@internal/sql-contract/entity-handle-lowe import type { SqlNamespaceBase, SqlNamespaceInput, + SqlStorage, StorageTypeInstance, } from '@internal/sql-contract/types'; import { blindCast } from '@internal/utils/casts'; @@ -590,7 +591,7 @@ export function defineContract( Record, Record> | undefined >, -): SqlContractResult { +): Contract { if (!isContractInput(definition)) { throw contractError( 'CONTRACT.ARGUMENT_INVALID', diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts index 94526bace037..2d1687cf0776 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-dsl.ts @@ -1448,6 +1448,7 @@ export class ContractModelBuilder< SqlSpec extends SqlStageSpec | undefined = undefined, IndexTypes extends IndexTypeMap = Record, TSpaceId extends string = '', + TNamespace extends string | undefined = string | undefined, > { declare readonly __name: ModelName; declare readonly __fields: Fields; @@ -1461,7 +1462,7 @@ export class ContractModelBuilder< constructor( readonly stageOne: { readonly modelName?: ModelName; - readonly namespace?: string; + readonly namespace?: Exclude; readonly fields: Fields; readonly relations: Relations; }, @@ -1519,7 +1520,8 @@ export class ContractModelBuilder< AttributesSpec, SqlSpec, IndexTypes, - TSpaceId + TSpaceId, + TNamespace > { const duplicateRelationName = findDuplicateRelationName(this.stageOne.relations, relations); if (duplicateRelationName) { @@ -1563,7 +1565,8 @@ export class ContractModelBuilder< NextAttributesSpec, SqlSpec, IndexTypes, - TSpaceId + TSpaceId, + TNamespace > { return new ContractModelBuilder( this.stageOne, @@ -1584,7 +1587,8 @@ export class ContractModelBuilder< AttributesSpec, never, IndexTypes, - TSpaceId + TSpaceId, + TNamespace > : ContractModelBuilder< ModelName, @@ -1593,7 +1597,8 @@ export class ContractModelBuilder< AttributesSpec, NextSqlSpec, IndexTypes, - TSpaceId + TSpaceId, + TNamespace > { // Conditional return type cannot be verified by the implementation; the runtime value is always a valid ContractModelBuilder regardless of the validation outcome (validation is type-level only). // When specOrFactory is a static object (not a function), extract tableName for the cross-space coordinate. @@ -1815,23 +1820,43 @@ export function model< const ModelName extends string, Fields extends Record, Relations extends Record = Record, + const TNamespace extends string | undefined = undefined, >( modelName: ModelName, input: { readonly fields: Fields; readonly relations?: Relations; - readonly namespace?: string; + readonly namespace?: TNamespace; }, -): ContractModelBuilder; +): ContractModelBuilder< + ModelName, + Fields, + Relations, + undefined, + undefined, + Record, + '', + NoInfer +>; export function model< Fields extends Record, Relations extends Record = Record, + const TNamespace extends string | undefined = undefined, >(input: { readonly fields: Fields; readonly relations?: Relations; - readonly namespace?: string; -}): ContractModelBuilder; + readonly namespace?: TNamespace; +}): ContractModelBuilder< + undefined, + Fields, + Relations, + undefined, + undefined, + Record, + '', + NoInfer +>; export function model< const ModelName extends string, diff --git a/packages/2-sql/2-authoring/contract-ts/src/contract-types.ts b/packages/2-sql/2-authoring/contract-ts/src/contract-types.ts index 62a1f012c03d..f3824d6a2edd 100644 --- a/packages/2-sql/2-authoring/contract-ts/src/contract-types.ts +++ b/packages/2-sql/2-authoring/contract-ts/src/contract-types.ts @@ -17,6 +17,7 @@ import type { StorageTypeInstance, TypeMaps, } from '@internal/sql-contract/types'; +import type { AggregateTypesFromPacks } from './aggregate-types'; import type { UnionToIntersection } from './authoring-type-utils'; import type { AttributeStageIdFieldNames, FieldStateOf, ScalarFieldBuilder } from './contract-dsl'; import type { EnumTypeHandle } from './enum-type'; @@ -32,7 +33,7 @@ export type ExtractCodecTypesFromPack

= P extends { __codecTypes?: infer C extends Record; } ? PublicCodecTypes - : Record; + : Record; export type MergeExtensionCodecTypes> = UnionToIntersection< { @@ -43,9 +44,9 @@ export type MergeExtensionCodecTypes> = Un type MergeExtensionCodecTypesSafe = Packs extends Record ? keyof Packs extends never - ? Record + ? Record : MergeExtensionCodecTypes - : Record; + : Record; export type ExtractIndexTypesFromPack

= P extends { readonly indexTypes: IndexTypeRegistration; @@ -75,9 +76,11 @@ export type MergeExtensionPackRefs< > = Existing extends Record ? Existing & Added : Added; type DefinitionExtensions = Definition extends { - readonly extensions?: infer Packs extends Record>; + readonly extensions?: infer Packs; } - ? Packs + ? [Exclude] extends [never] + ? Record + : Exclude : Record; type ExtractPackCapabilities

= P extends { @@ -286,7 +289,11 @@ type FieldNullableOf = FieldState extends { ? Nullable : boolean; -type FieldManyOf = FieldState extends { readonly many?: true } ? true : false; +type FieldManyOf = FieldState extends { readonly many?: infer Many } + ? true extends Many + ? true + : false + : false; type FieldColumnOverrideOf = Present< FieldState extends { readonly columnName?: infer ColumnName } ? ColumnName : never @@ -512,9 +519,70 @@ type ModelStorageColumn< > : never; +type RelationModelName = Source extends { readonly modelName: infer Name extends string } + ? Name + : Source extends { readonly resolve: () => infer Name extends string } + ? Name + : never; + +type FieldTuple = Fields extends readonly string[] + ? Fields + : Fields extends string + ? readonly [Fields] + : readonly []; + +type RelationNullable, Fields> = true extends { + [Field in Extract< + FieldTuple[number], + ModelFieldNames + >]: FieldNullableOf>; +}[Extract[number], ModelFieldNames>] + ? true + : false; + +type BuiltRelation< + Definition, + ModelName extends ModelNames, + Builder, +> = Builder extends { readonly __state: infer State } + ? State extends { readonly kind: infer Kind; readonly toModel: infer Target } + ? { + readonly to: { + readonly namespace: RelationModelName extends ModelNames + ? ModelNamespaceId> & NamespaceId + : NamespaceId; + readonly model: RelationModelName; + }; + } & (State extends { + readonly kind: 'belongsTo'; + readonly from: infer From; + readonly to: infer To; + } + ? { + readonly cardinality: 'N:1'; + readonly nullable: RelationNullable; + readonly on: { + readonly localFields: FieldTuple; + readonly targetFields: FieldTuple; + }; + } + : State extends { readonly kind: 'hasOne' | 'hasMany'; readonly by: infer By } + ? { + readonly cardinality: Kind extends 'hasOne' ? '1:1' : '1:N'; + readonly nullable: true; + readonly on: { + readonly localFields: FieldTuple>; + readonly targetFields: FieldTuple; + }; + } + : Omit, 'to'>) + : never + : never; + type BuiltModels = { readonly [ModelName in ModelNames]: { readonly storage: { + readonly namespaceId: ModelNamespaceId; readonly table: ModelTableName; readonly fields: { readonly [FieldName in ModelFieldNames]: { @@ -532,7 +600,11 @@ type BuiltModels = { }; }; readonly relations: { - readonly [RelName in StagedModelRelationNames]: ContractRelation; + readonly [RelName in StagedModelRelationNames]: BuiltRelation< + Definition, + ModelName, + StagedModelRelations[RelName] + >; }; }; }; @@ -555,8 +627,11 @@ type BuiltStorageTableColumns[FieldName]['column']]: ModelStorageColumn; }; -type BuiltStorageTables = { - readonly [ModelName in ModelNames as BuiltModelTableName]: { +type BuiltStorageTables = { + readonly [ModelName in ModelsInNamespace as BuiltModelTableName< + Definition, + ModelName + >]: { readonly columns: BuiltStorageTableColumns; readonly uniques: ReadonlyArray<{ readonly columns: readonly string[]; @@ -632,58 +707,56 @@ type BuiltDocumentScopedTypes = { : never]: DefinitionTypes[K]; }; -type BuiltDomain = - BuiltDocumentScopedTypes extends Record - ? Record - : { - readonly __unbound__: { - readonly types: BuiltDocumentScopedTypes; - }; - }; - -// Per-namespace domain entry carrying the precise per-model field/storage shapes -// for DSL inference. Modelled as an index signature (rather than enumerating -// namespace ids) so that any namespace coordinate resolves the full model map, -// matching how the authoring path lumps every model under the default storage -// namespace. -type BuiltDomainNamespace = { - readonly models: BuiltModels; +type BuiltDomainNamespace = { + readonly models: Pick, ModelsInNamespace>; readonly valueObjects?: Record; readonly enum?: Record; }; type DefaultStorageNamespaceId = - DefinitionTargetId extends 'postgres' ? 'public' : '__unbound__'; + DefinitionTarget extends { + readonly defaultNamespaceId: infer Ns extends string; + } + ? Ns + : '__unbound__'; + +type ModelNamespaceId< + Definition, + ModelName extends ModelNames, +> = DefinitionModels[ModelName] extends { + readonly stageOne: { readonly namespace?: infer Ns }; +} + ? [Exclude] extends [never] + ? DefaultStorageNamespaceId + : Exclude extends infer Name extends string + ? Name extends 'unbound' + ? '__unbound__' + : Name + : DefaultStorageNamespaceId + : DefaultStorageNamespaceId; + +type ModelsInNamespace = { + [Name in ModelNames]: ModelNamespaceId extends Ns ? Name : never; +}[ModelNames]; + +type ModelNamespaceIds = { + [Name in ModelNames]: ModelNamespaceId; +}[ModelNames]; + +type StorageNamespaceIds = + | DefaultStorageNamespaceId + | DefinitionNamespaces + | ModelNamespaceIds; type BuiltStorage = { readonly storageHash: StorageHashBase; readonly types?: BuiltDocumentScopedTypes; - // The primary namespace key is target-specific: Postgres uses `public` (the - // default schema), all other SQL targets use `__unbound__`. The namespace - // carries the narrowed `entries.table` shape so downstream DSL surfaces keep - // literal-keyed access without an optional-narrowing dance. The shape is - // described inline (rather than intersecting with `SqlStorage['namespaces']`) - // so its `Readonly>` index signature doesn't - // collapse slot keys to `string`. The literal object is still structurally - // assignable to `SqlStorage['namespaces']` because every value satisfies the - // framework `Namespace` interface. readonly namespaces: { - readonly [K in DefaultStorageNamespaceId]: { - readonly id: K; - readonly kind: string; - readonly entries: { - readonly table: BuiltStorageTables; - }; - }; - } & { - readonly [Ns in Exclude< - DefinitionNamespaces, - DefaultStorageNamespaceId - >]: { + readonly [Ns in StorageNamespaceIds]: { readonly id: Ns; readonly kind: string; readonly entries: { - readonly table: Record; + readonly table: BuiltStorageTables; }; }; }; @@ -768,14 +841,9 @@ type FieldChannelType< ? null : never); -// Nested by namespace coordinate (`{ [ns]: { [model]: { [field]: type } } }`) -// to mirror the emitter's namespace-nested `FieldOutputTypes` (and the -// `TypeMaps` constraint). The TS authoring path lumps every model under the -// target's default storage namespace (see `BuiltStorage`), so the per-model -// field-type map nests under that same coordinate. type FieldChannelTypes = { - readonly [Ns in DefaultStorageNamespaceId]: { - readonly [ModelName in ModelNames]: { + readonly [Ns in StorageNamespaceIds]: { + readonly [ModelName in ModelsInNamespace]: { readonly [FieldName in ModelFieldNames]: FieldChannelType< Definition, ModelName, @@ -787,8 +855,11 @@ type FieldChannelTypes = { }; type StorageColumnChannelTypes = { - readonly [Ns in DefaultStorageNamespaceId]: { - readonly [ModelName in ModelNames as BuiltModelTableName]: { + readonly [Ns in StorageNamespaceIds]: { + readonly [ModelName in ModelsInNamespace as BuiltModelTableName< + Definition, + ModelName + >]: { readonly [FieldName in ModelFieldNames as BuiltModelColumnMappings< Definition, ModelName @@ -803,8 +874,10 @@ export type SqlContractResult = ContractWithTypeMaps< readonly targetFamily: 'sql'; } & { readonly domain: { - readonly namespaces: Readonly>>; - } & BuiltDomain; + readonly namespaces: { + readonly [Ns in ModelNamespaceIds]: BuiltDomainNamespace; + }; + }; } & { readonly extensions: keyof DefinitionExtensions extends never ? Record @@ -818,6 +891,7 @@ export type SqlContractResult = ContractWithTypeMaps< FieldChannelTypes, FieldChannelTypes, StorageColumnChannelTypes, - StorageColumnChannelTypes + StorageColumnChannelTypes, + AggregateTypesFromPacks, CodecTypesFromDefinition> > >; diff --git a/packages/3-extensions/postgres/package.json b/packages/3-extensions/postgres/package.json index e5e95970fdf6..e29e732ec59b 100644 --- a/packages/3-extensions/postgres/package.json +++ b/packages/3-extensions/postgres/package.json @@ -11,7 +11,7 @@ "emit": "cd ../../../test/integration && node ../../packages/1-framework/3-tooling/cli/dist/bin.mjs contract emit --config test/sql-builder/fixtures/prisma.config.no-pgvector.ts && cp test/sql-builder/fixtures/generated-no-pgvector/contract.json test/sql-builder/fixtures/generated-no-pgvector/contract.d.ts ../../packages/3-extensions/postgres/test/fixtures/generated/", "emit:check": "pnpm emit && git diff --exit-code test/fixtures/generated/", "test": "vitest run", - "typecheck": "tsc --project tsconfig.json --noEmit", + "typecheck": "tsc --project tsconfig.json --noEmit && tsc --project tsconfig.native-contract.json --noEmit", "lint": "biome check . --error-on-warnings", "lint:fix": "biome check --write .", "lint:fix:unsafe": "biome check --write --unsafe .", diff --git a/packages/3-extensions/postgres/src/contract/define-contract.ts b/packages/3-extensions/postgres/src/contract/define-contract.ts index aab3531cad72..f2eb663070ae 100644 --- a/packages/3-extensions/postgres/src/contract/define-contract.ts +++ b/packages/3-extensions/postgres/src/contract/define-contract.ts @@ -1,8 +1,10 @@ +import type { Contract } from '@internal/contract/types'; import sqlFamilyPack from '@internal/family-sql/pack'; import type { ExtensionPackRef } from '@internal/framework-components/components'; import type { SqlNamespaceBase, SqlNamespaceInput, + SqlStorage, StorageTypeInstance, } from '@internal/sql-contract/types'; import type { @@ -29,6 +31,8 @@ type PostgresResult< Models extends ModelsConstraint, Extensions extends Record> | undefined, Enums extends EnumsConstraint, + Naming extends ContractInput['naming'] | undefined, + Namespaces extends readonly string[] | undefined, > = ReturnType< typeof buildBoundContract< SqlFamily, @@ -39,6 +43,8 @@ type PostgresResult< readonly extensions?: Extensions; readonly enums?: Enums; readonly createNamespace: (input: SqlNamespaceInput) => SqlNamespaceBase; + readonly naming?: Naming; + readonly namespaces?: Namespaces; } > >; @@ -84,9 +90,17 @@ export function defineContract< const Models extends ModelsConstraint = Record, const Extensions extends Record> | undefined = undefined, const Enums extends EnumsConstraint = Record, + const Naming extends ContractInput['naming'] | undefined = undefined, + const Namespaces extends readonly string[] | undefined = undefined, >( - definition: PostgresDefinition, -): PostgresResult; + definition: Omit< + PostgresDefinition, + 'naming' | 'namespaces' + > & { + readonly naming?: Naming; + readonly namespaces?: Namespaces; + }, +): PostgresResult; export function defineContract< const Types extends TypesConstraint = Record, @@ -94,14 +108,26 @@ export function defineContract< const Extensions extends Record> | undefined = undefined, const ScaffoldEnums extends EnumsConstraint = Record, const FactoryEnums extends EnumsConstraint = Record, + const Naming extends ContractInput['naming'] | undefined = undefined, + const Namespaces extends readonly string[] | undefined = undefined, >( - scaffold: PostgresScaffold, + scaffold: Omit, 'naming' | 'namespaces'> & { + readonly naming?: Naming; + readonly namespaces?: Namespaces; + }, factory: (helpers: ComposedAuthoringHelpers) => { readonly types?: Types; readonly models?: Models; readonly enums?: FactoryEnums; }, -): PostgresResult>; +): PostgresResult< + Types, + Models, + Extensions, + MergeEnums, + Naming, + Namespaces +>; // Implementation — delegates to buildBoundContract which pre-binds family/target, // carrying zero casts and zero entity-kind logic at this layer: the generic @@ -113,7 +139,7 @@ export function defineContract( readonly models?: ModelsConstraint; readonly enums?: EnumsConstraint; }, -): PostgresResult { +): Contract { const bound = { ...definition, createNamespace: postgresCreateNamespace }; if (factory !== undefined) { return buildBoundContract(sqlFamilyPack, postgresPack, bound, factory); diff --git a/packages/3-extensions/postgres/test/contract-builder/native-client.test-d.ts b/packages/3-extensions/postgres/test/contract-builder/native-client.test-d.ts new file mode 100644 index 000000000000..af5dd287edfe --- /dev/null +++ b/packages/3-extensions/postgres/test/contract-builder/native-client.test-d.ts @@ -0,0 +1,168 @@ +import type { + ExtractAggregateTypes, + ExtractFieldOutputTypes, + ExtractStorageColumnTypes, +} from '@internal/sql-contract/types'; +import { expectTypeOf, test } from 'vitest'; +import { defineContract, field, model } from '../../src/exports/contract-builder'; +import type postgres from '../../src/exports/runtime'; +import type { Contract } from '../fixtures/generated/contract'; + +const contract = defineContract({}, ({ field, model, rel }) => { + const User = model('User', { + fields: { + id: field.int().id(), + email: field.text(), + name: field.text().optional(), + tags: field.text().many().optional(), + }, + }); + const Post = model('Post', { + fields: { + id: field.int().id(), + authorId: field.int(), + editorId: field.int().optional(), + title: field.text(), + }, + relations: { + author: rel.belongsTo(User, { from: 'authorId', to: 'id' }), + editor: rel.belongsTo(() => User, { from: 'editorId', to: 'id' }), + }, + }); + return { + models: { User: User.relations({ posts: rel.hasMany(Post, { by: 'authorId' }) }), Post }, + }; +}); +declare const db: ReturnType>; + +test('infers scalar and list rows without emitted contract declarations', () => { + expectTypeOf(db.orm.public.User.all()).resolves.toEqualTypeOf< + Array<{ + id: number; + email: string; + name: string | null; + tags: ReadonlyArray | null; + }> + >(); + // @ts-expect-error scalar numeric filters reject strings + db.orm.public.User.where({ id: 'wrong' }); + // @ts-expect-error scalar string filters reject lists + db.orm.public.User.where({ email: ['wrong'] }); + // @ts-expect-error undeclared models do not become an index signature + db.orm.public.Missing; + // @ts-expect-error undeclared namespaces do not become an index signature + db.orm.missing.User; +}); + +test('retains required, nullable, lazy, and to-many relation metadata', () => { + expectTypeOf( + db.orm.public.Post.select('id') + .include('editor', (editor) => editor.select('email')) + .all(), + ).resolves.toEqualTypeOf< + Array<{ + id: number; + editor: { email: string } | null; + }> + >(); + expectTypeOf(db.orm.public.Post.select('title').include('author').all()).resolves.toEqualTypeOf< + Array<{ + title: string; + author: { + id: number; + email: string; + name: string | null; + tags: ReadonlyArray | null; + }; + }> + >(); + expectTypeOf( + db.orm.public.User.select('id') + .include('posts', (posts) => posts.select('title')) + .all(), + ).resolves.toEqualTypeOf< + Array<{ + id: number; + posts: Array<{ title: string }>; + }> + >(); +}); + +type Materialize = Value extends object + ? { [Key in keyof Value]: Materialize } + : Value; + +test('types aggregate results from the target descriptors', () => { + expectTypeOf>>().toEqualTypeOf< + ExtractAggregateTypes + >(); + expectTypeOf( + db.orm.public.User.aggregate((aggregate) => ({ + total: aggregate.count(), + sum: aggregate.sum('id'), + largest: aggregate.max('email'), + })), + ).resolves.toEqualTypeOf<{ total: number; sum: number | null; largest: string | null }>(); + db.orm.public.User.aggregate((aggregate) => ({ + // @ts-expect-error Postgres cannot sum text + invalid: aggregate.sum('email'), + })); +}); + +test('keeps prepared scalar comparisons scalar', async () => { + const prepared = await db.prepare({ email: 'pg/text@1' }, (params) => + db.sql.public.User.select('id', 'email') + .where((fields, fns) => fns.eq(fields.email, params.email)) + .build(), + ); + expectTypeOf[1]>().toEqualTypeOf<{ readonly email: string }>(); +}); + +const direct = defineContract({ + extensions: {}, + models: { + Account: model('Account', { + fields: { + id: field.column({ codecId: 'pg/int4@1', nativeType: 'int4' } as const).id(), + }, + }), + }, +}); +declare const directDb: ReturnType>; +test('infers direct definitions with empty extensions', () => { + expectTypeOf(directDb.orm.public.Account.all()).resolves.toEqualTypeOf>(); + // @ts-expect-error unknown scaffold properties remain rejected + defineContract({ unknownOption: true }); +}); + +const namespaced = defineContract( + { namespaces: ['auth'], naming: { tables: 'snake_case', columns: 'snake_case' } }, + ({ field, model }) => ({ + models: { + AccountProfile: model('AccountProfile', { + namespace: 'auth', + fields: { + id: field.int().id(), + loginName: field.text(), + }, + }) + .relations({}) + .attributes({}) + .sql({}), + }, + }), +); +declare const namespacedDb: ReturnType>; +test('retains scaffold naming and model namespaces through fluent stages', () => { + expectTypeOf< + ExtractFieldOutputTypes['auth']['AccountProfile']['loginName'] + >().toEqualTypeOf(); + expectTypeOf< + ExtractStorageColumnTypes['auth']['account_profile']['login_name'] + >().toEqualTypeOf(); + expectTypeOf(namespacedDb.orm.auth.AccountProfile.all()).resolves.toEqualTypeOf< + Array<{ id: number; loginName: string }> + >(); + // @ts-expect-error a model exists only in its authored namespace + namespacedDb.orm.public.AccountProfile; +}); diff --git a/packages/3-extensions/postgres/tsconfig.native-contract.json b/packages/3-extensions/postgres/tsconfig.native-contract.json new file mode 100644 index 000000000000..37ba42060e6e --- /dev/null +++ b/packages/3-extensions/postgres/tsconfig.native-contract.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "exactOptionalPropertyTypes": false + }, + "include": ["test/contract-builder/native-client.test-d.ts"] +} diff --git a/packages/3-targets/3-targets/postgres/src/core/aggregates.ts b/packages/3-targets/3-targets/postgres/src/core/aggregates.ts index b525d7461495..8458253ce49e 100644 --- a/packages/3-targets/3-targets/postgres/src/core/aggregates.ts +++ b/packages/3-targets/3-targets/postgres/src/core/aggregates.ts @@ -52,40 +52,55 @@ import { /** The input matches available to an overload that consumes a value — the only ones these helpers build, since every aggregate here but `count` needs something to fold. */ type ValueInput = ValueInputAggregateDescriptor['input']; -const overCodec = (codecId: string): ValueInput => ({ kind: 'codec', codecId }); -const overTrait = (trait: CodecTrait): ValueInput => ({ kind: 'trait', trait }); +const overCodec = (codecId: CodecId) => + ({ kind: 'codec', codecId }) as const; +const overTrait = (trait: Trait) => + ({ kind: 'trait', trait }) as const; /** * An aggregate whose result is one of the input values, so it carries the input's codec — type parameters included, since a `numeric(10,3)` minimum is still a `numeric(10,3)`. */ -const preservesInput = (operation: string, input: ValueInput): SqlAggregateDescriptor => ({ - operation, - input, - output: { kind: 'self' }, - nullable: true, -}); +const preservesInput = ( + operation: Operation, + input: Input, +) => + ({ + operation, + input, + output: { kind: 'self' }, + nullable: true, + }) as const; /** * An aggregate whose result is a new value. It names its result codec without type parameters: a sum leaves the input's width behind (a `numeric(10,3)` column sums to an unconstrained `numeric`), so carrying the input's parameters into the result would understate the range. */ -const produces = ( - operation: string, - input: ValueInput, - codecId: string, -): SqlAggregateDescriptor => ({ - operation, - input, - output: { kind: 'codec', codecId }, - nullable: true, -}); +const produces = < + const Operation extends string, + const Input extends ValueInput, + const CodecId extends string, +>( + operation: Operation, + input: Input, + codecId: CodecId, +) => + ({ + operation, + input, + output: { kind: 'codec', codecId }, + nullable: true, + }) as const; /** The same, for an operation that builds its own expression. */ -const producesVia = ( - operation: string, - input: ValueInput, - codecId: string, +const producesVia = < + const Operation extends string, + const Input extends ValueInput, + const CodecId extends string, +>( + operation: Operation, + input: Input, + codecId: CodecId, lower: SqlAggregateLowering, -): SqlAggregateDescriptor => ({ ...produces(operation, input, codecId), lower }); +) => ({ ...produces(operation, input, codecId), lower }); /** The SQL aggregate a lossless variant computes with. `sumBigInt` is a `sum` read exactly, `countBigInt` a `count`, `avgDecimal` an `avg`: the variants differ in how the result is read, never in what the database computes. */ const computedWith = @@ -145,7 +160,7 @@ const MIN_MAX_PRESERVING_CODECS = [ */ const MIN_MAX_WIDENS_TO_TEXT = [PG_VARCHAR_CODEC_ID, SQL_VARCHAR_CODEC_ID] as const; -const orderingDescriptors = (operation: 'min' | 'max'): ReadonlyArray => [ +const orderingDescriptors = (operation: Operation) => [ preservesInput(operation, overTrait('numeric')), preservesInput(operation, overTrait('textual')), ...MIN_MAX_WIDENS_TO_TEXT.map((codecId) => @@ -157,7 +172,7 @@ const orderingDescriptors = (operation: 'min' | 'max'): ReadonlyArray = [ +export const postgresAggregateDescriptors = [ // PostgreSQL's `count` returns `bigint` whether it counts entries or non-null values, which is what makes it input-agnostic rather than merely input-less. A row count is a `number` to a JS developer, so that is what the bare operation reads it as — and outside ±(2^53 − 1) it throws rather than answer with a rounded tally. { operation: 'count', @@ -221,4 +236,4 @@ export const postgresAggregateDescriptors: ReadonlyArray ...orderingDescriptors('min'), ...orderingDescriptors('max'), -]; +] as const satisfies ReadonlyArray; diff --git a/packages/3-targets/3-targets/postgres/src/core/codec-type-map.ts b/packages/3-targets/3-targets/postgres/src/core/codec-type-map.ts index 5d7302526e9e..fa5bea919f42 100644 --- a/packages/3-targets/3-targets/postgres/src/core/codec-type-map.ts +++ b/packages/3-targets/3-targets/postgres/src/core/codec-type-map.ts @@ -27,6 +27,7 @@ import { pgJsonbDescriptor, pgJsonDescriptor, pgNumericDescriptor, + pgTextArrayDescriptor, pgTextDescriptor, pgTimetzDescriptor, pgUnboundedIntDescriptor, @@ -60,6 +61,7 @@ export const codecDescriptorMap = { float: postgresSqlFloatDescriptor, 'sql-text': postgresSqlTextDescriptor, text: pgTextDescriptor, + 'text-array': pgTextArrayDescriptor, enum: pgEnumDescriptor, character: pgCharDescriptor, 'character varying': pgVarcharDescriptor, diff --git a/packages/3-targets/3-targets/postgres/src/core/descriptor-meta.ts b/packages/3-targets/3-targets/postgres/src/core/descriptor-meta.ts index 6bb88039954d..272557b20c4f 100644 --- a/packages/3-targets/3-targets/postgres/src/core/descriptor-meta.ts +++ b/packages/3-targets/3-targets/postgres/src/core/descriptor-meta.ts @@ -1,4 +1,5 @@ import type { CodecTypes } from '../exports/codec-types'; +import type { postgresAggregateDescriptors } from './aggregates'; import { postgresAuthoringEntityTypes, postgresAuthoringFieldPresets, @@ -34,4 +35,5 @@ const postgresTargetDescriptorMetaBase = { export const postgresTargetDescriptorMeta: typeof postgresTargetDescriptorMetaBase & { readonly __codecTypes?: CodecTypes; + readonly __aggregateDescriptors?: typeof postgresAggregateDescriptors; } = postgresTargetDescriptorMetaBase;