diff --git a/packages/2-mongo-family/9-family/src/exports/pack.ts b/packages/2-mongo-family/9-family/src/exports/pack.ts index e7e07c5d73..1e249b1797 100644 --- a/packages/2-mongo-family/9-family/src/exports/pack.ts +++ b/packages/2-mongo-family/9-family/src/exports/pack.ts @@ -1,5 +1,3 @@ -import type { FamilyPackRef } from '@prisma-next/framework-components/components'; - const mongoFamilyPack = { kind: 'family', id: 'mongo', @@ -7,4 +5,4 @@ const mongoFamilyPack = { version: '0.0.1', } as const; -export default mongoFamilyPack as typeof mongoFamilyPack & FamilyPackRef<'mongo'>; +export default mongoFamilyPack; diff --git a/packages/2-sql/9-family/src/core/authoring-field-presets.ts b/packages/2-sql/9-family/src/core/authoring-field-presets.ts index d3054be768..53af1a0799 100644 --- a/packages/2-sql/9-family/src/core/authoring-field-presets.ts +++ b/packages/2-sql/9-family/src/core/authoring-field-presets.ts @@ -1,5 +1,21 @@ import type { AuthoringFieldNamespace } from '@prisma-next/framework-components/authoring'; +/** + * Family-level SQL authoring field presets. + * + * Only presets whose codec IDs align with the ID generator metadata live here + * (see `@prisma-next/ids`). These presets are target-agnostic because the + * generator metadata fixes their codec/native-type to `sql/char@1` + * (`character`) regardless of target, and the PSL interpreter lets the + * generator override the scalar descriptor. + * + * Scalar presets that map to target-specific codecs (e.g. `text`, `int`, + * `boolean`, `dateTime`) are contributed by the target pack (see + * `postgresAuthoringFieldPresets` in `@prisma-next/target-postgres`) so the + * TS callback surface and the PSL scalar surface lower to byte-identical + * contracts for the active target. + */ + const CHARACTER_CODEC_ID = 'sql/char@1'; const CHARACTER_NATIVE_TYPE = 'character'; @@ -18,31 +34,6 @@ const nanoidOptionsArgument = { } as const; export const sqlFamilyAuthoringFieldPresets = { - text: { - kind: 'fieldPreset', - output: { - codecId: 'sql/text@1', - nativeType: 'text', - }, - }, - timestamp: { - kind: 'fieldPreset', - output: { - codecId: 'sql/timestamp@1', - nativeType: 'timestamp', - }, - }, - createdAt: { - kind: 'fieldPreset', - output: { - codecId: 'sql/timestamp@1', - nativeType: 'timestamp', - default: { - kind: 'function', - expression: 'CURRENT_TIMESTAMP', - }, - }, - }, uuid: { kind: 'fieldPreset', output: { diff --git a/packages/3-extensions/pgvector/src/exports/pack.ts b/packages/3-extensions/pgvector/src/exports/pack.ts index 8208e5a8fd..f7f921b06f 100644 --- a/packages/3-extensions/pgvector/src/exports/pack.ts +++ b/packages/3-extensions/pgvector/src/exports/pack.ts @@ -1,10 +1,8 @@ -import type { ExtensionPackRef } from '@prisma-next/framework-components/components'; import { pgvectorPackMeta } from '../core/descriptor-meta'; import type { CodecTypes } from '../types/codec-types'; const pgvectorPack = pgvectorPackMeta; -export default pgvectorPack as typeof pgvectorPackMeta & - ExtensionPackRef<'sql', 'postgres'> & { - readonly __codecTypes?: CodecTypes; - }; +export default pgvectorPack as typeof pgvectorPackMeta & { + readonly __codecTypes?: CodecTypes; +}; diff --git a/packages/3-mongo-target/1-mongo-target/src/exports/pack.ts b/packages/3-mongo-target/1-mongo-target/src/exports/pack.ts index ed895b7e8c..811e9442b2 100644 --- a/packages/3-mongo-target/1-mongo-target/src/exports/pack.ts +++ b/packages/3-mongo-target/1-mongo-target/src/exports/pack.ts @@ -1,10 +1,8 @@ -import type { TargetPackRef } from '@prisma-next/framework-components/components'; import { mongoTargetDescriptorMeta } from '../core/descriptor-meta'; import type { CodecTypes } from './codec-types'; const mongoTargetPack = mongoTargetDescriptorMeta; -export default mongoTargetPack as typeof mongoTargetPack & - TargetPackRef<'mongo', 'mongo'> & { - readonly __codecTypes?: CodecTypes; - }; +export default mongoTargetPack as typeof mongoTargetPack & { + readonly __codecTypes?: CodecTypes; +}; diff --git a/packages/3-targets/3-targets/postgres/src/core/authoring.ts b/packages/3-targets/3-targets/postgres/src/core/authoring.ts index 5474c60032..3046cb9f80 100644 --- a/packages/3-targets/3-targets/postgres/src/core/authoring.ts +++ b/packages/3-targets/3-targets/postgres/src/core/authoring.ts @@ -1,4 +1,7 @@ -import type { AuthoringTypeNamespace } from '@prisma-next/framework-components/authoring'; +import type { + AuthoringFieldNamespace, + AuthoringTypeNamespace, +} from '@prisma-next/framework-components/authoring'; export const postgresAuthoringTypes = { enum: { @@ -13,3 +16,88 @@ export const postgresAuthoringTypes = { }, }, } as const satisfies AuthoringTypeNamespace; + +/** + * Field presets contributed by the Postgres target pack. + * + * These mirror the PSL scalar-to-codec mapping used by the Postgres adapter + * (see `createPostgresPslScalarTypeDescriptors`), so that authoring a field + * via the TS callback surface (e.g. `field.int()`) and via the PSL scalar + * surface (e.g. `Int`) lowers to byte-identical contracts. + */ +export const postgresAuthoringFieldPresets = { + text: { + kind: 'fieldPreset', + output: { + codecId: 'pg/text@1', + nativeType: 'text', + }, + }, + int: { + kind: 'fieldPreset', + output: { + codecId: 'pg/int4@1', + nativeType: 'int4', + }, + }, + bigint: { + kind: 'fieldPreset', + output: { + codecId: 'pg/int8@1', + nativeType: 'int8', + }, + }, + float: { + kind: 'fieldPreset', + output: { + codecId: 'pg/float8@1', + nativeType: 'float8', + }, + }, + decimal: { + kind: 'fieldPreset', + output: { + codecId: 'pg/numeric@1', + nativeType: 'numeric', + }, + }, + boolean: { + kind: 'fieldPreset', + output: { + codecId: 'pg/bool@1', + nativeType: 'bool', + }, + }, + json: { + kind: 'fieldPreset', + output: { + codecId: 'pg/jsonb@1', + nativeType: 'jsonb', + }, + }, + bytes: { + kind: 'fieldPreset', + output: { + codecId: 'pg/bytea@1', + nativeType: 'bytea', + }, + }, + dateTime: { + kind: 'fieldPreset', + output: { + codecId: 'pg/timestamptz@1', + nativeType: 'timestamptz', + }, + }, + createdAt: { + kind: 'fieldPreset', + output: { + codecId: 'pg/timestamptz@1', + nativeType: 'timestamptz', + default: { + kind: 'function', + expression: 'now()', + }, + }, + }, +} as const satisfies AuthoringFieldNamespace; 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 5f15ac9906..b1079d0596 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,4 @@ -import { postgresAuthoringTypes } from './authoring'; +import { postgresAuthoringFieldPresets, postgresAuthoringTypes } from './authoring'; export const postgresTargetDescriptorMeta = { kind: 'target', @@ -9,5 +9,6 @@ export const postgresTargetDescriptorMeta = { capabilities: {}, authoring: { type: postgresAuthoringTypes, + field: postgresAuthoringFieldPresets, }, } as const; diff --git a/packages/3-targets/3-targets/postgres/src/exports/pack.ts b/packages/3-targets/3-targets/postgres/src/exports/pack.ts index 6c68c7f72c..a8f540bb0a 100644 --- a/packages/3-targets/3-targets/postgres/src/exports/pack.ts +++ b/packages/3-targets/3-targets/postgres/src/exports/pack.ts @@ -1,10 +1,8 @@ import type { CodecTypes } from '@prisma-next/adapter-postgres/codec-types'; -import type { TargetPackRef } from '@prisma-next/framework-components/components'; import { postgresTargetDescriptorMeta } from '../core/descriptor-meta'; const postgresPack = postgresTargetDescriptorMeta; -export default postgresPack as typeof postgresTargetDescriptorMeta & - TargetPackRef<'sql', 'postgres'> & { - readonly __codecTypes?: CodecTypes; - }; +export default postgresPack as typeof postgresTargetDescriptorMeta & { + readonly __codecTypes?: CodecTypes; +}; diff --git a/packages/3-targets/3-targets/sqlite/src/exports/pack.ts b/packages/3-targets/3-targets/sqlite/src/exports/pack.ts index de4f9afb48..e8d1e89844 100644 --- a/packages/3-targets/3-targets/sqlite/src/exports/pack.ts +++ b/packages/3-targets/3-targets/sqlite/src/exports/pack.ts @@ -1,9 +1,8 @@ import type { CodecTypes } from '@prisma-next/adapter-sqlite/codec-types'; -import type { TargetPackRef } from '@prisma-next/framework-components/components'; import { sqliteTargetDescriptorMeta } from '../core/descriptor-meta'; const sqlitePack = sqliteTargetDescriptorMeta; -export default sqlitePack as TargetPackRef<'sql', 'sqlite'> & { +export default sqlitePack as typeof sqliteTargetDescriptorMeta & { readonly __codecTypes?: CodecTypes; }; diff --git a/test/integration/test/authoring/callback-mode-terseness.test.ts b/test/integration/test/authoring/callback-mode-terseness.test.ts new file mode 100644 index 0000000000..7ec7807e4e --- /dev/null +++ b/test/integration/test/authoring/callback-mode-terseness.test.ts @@ -0,0 +1,52 @@ +import { readFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const fixtureDir = join(__dirname, 'parity', 'callback-mode-scalars'); + +/** + * Counts semantic lines in a source file — non-blank lines that are not + * comments. Matches the heuristic used by the contract-psl ts-psl-parity + * test so results are comparable across parity tests. + */ +function countSemanticLines(source: string): number { + return source + .split('\n') + .map((line) => line.trim()) + .filter((line) => line.length > 0 && !line.startsWith('//')).length; +} + +describe('VP2: TS callback-mode authoring terseness parity', () => { + const pslSource = readFileSync(join(fixtureDir, 'schema.prisma'), 'utf-8'); + const tsSource = readFileSync(join(fixtureDir, 'contract.ts'), 'utf-8'); + const pslLines = countSemanticLines(pslSource); + const tsLines = countSemanticLines(tsSource); + const ratio = tsLines / pslLines; + + it('keeps the callback-mode TS contract in the ~1.5–2.1x PSL ballpark', () => { + // VP2 stop condition: "The TypeScript version of a representative + // contract is in the same ballpark of length as the PSL version." + // + // Baseline (April milestone): structural TS authoring was ~3–5x the + // PSL version. The callback-mode field presets (contributed by + // @prisma-next/target-postgres/pack) should collapse scalar fields to + // one line each, pulling the ratio well under the baseline. + // + // The upper bound of 2.1x is intentional: any drift above 2.1x should + // force a re-review of the preset vocabulary rather than silently + // widen the acceptance window. + expect(ratio).toBeLessThanOrEqual(2.1); + expect(ratio).toBeGreaterThan(0); + }); + + it('is measurably tighter than the structural core-surface baseline', () => { + const coreSurfaceDir = join(__dirname, 'parity', 'core-surface'); + const coreSurfacePsl = readFileSync(join(coreSurfaceDir, 'schema.prisma'), 'utf-8'); + const coreSurfaceTs = readFileSync(join(coreSurfaceDir, 'contract.ts'), 'utf-8'); + const coreRatio = countSemanticLines(coreSurfaceTs) / countSemanticLines(coreSurfacePsl); + + expect(ratio).toBeLessThan(coreRatio); + }); +}); diff --git a/test/integration/test/authoring/parity/callback-mode-scalars/contract.ts b/test/integration/test/authoring/parity/callback-mode-scalars/contract.ts new file mode 100644 index 0000000000..c8f6f6b99c --- /dev/null +++ b/test/integration/test/authoring/parity/callback-mode-scalars/contract.ts @@ -0,0 +1,39 @@ +import pgvector from '@prisma-next/extension-pgvector/pack'; +import sqlFamily from '@prisma-next/family-sql/pack'; +import { defineContract, rel } from '@prisma-next/sql-contract-ts/contract-builder'; +import postgresPack from '@prisma-next/target-postgres/pack'; + +export const contract = defineContract( + { family: sqlFamily, target: postgresPack, extensionPacks: { pgvector } }, + ({ field, model, type }) => { + const types = { + Embedding: type.pgvector.Vector(1536), + } as const; + const User = model('User', { + fields: { + id: field.int().defaultSql('autoincrement()').id(), + email: field.text().unique(), + age: field.int(), + isActive: field.boolean().default(true), + score: field.float().optional(), + profile: field.json().optional(), + embedding: field.namedType(types.Embedding).optional(), + createdAt: field.createdAt(), + }, + }).sql({ table: 'user' }); + const Post = model('Post', { + fields: { + id: field.int().defaultSql('autoincrement()').id(), + userId: field.int(), + title: field.text(), + rating: field.float().optional(), + }, + relations: { + user: rel + .belongsTo(User, { from: 'userId', to: 'id' }) + .sql({ fk: { onDelete: 'cascade', onUpdate: 'cascade' } }), + }, + }).sql({ table: 'post' }); + return { types, models: { User, Post } }; + }, +); diff --git a/test/integration/test/authoring/parity/callback-mode-scalars/expected.contract.json b/test/integration/test/authoring/parity/callback-mode-scalars/expected.contract.json new file mode 100644 index 0000000000..3113ab7fb0 --- /dev/null +++ b/test/integration/test/authoring/parity/callback-mode-scalars/expected.contract.json @@ -0,0 +1,361 @@ +{ + "schemaVersion": "1", + "targetFamily": "sql", + "target": "postgres", + "profileHash": "sha256:1a8dbe044289f30a1de958fe800cc5a8378b285d2e126a8c44b58864bac2c18e", + "roots": { + "post": "Post", + "user": "User" + }, + "models": { + "Post": { + "fields": { + "id": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "rating": { + "nullable": true, + "type": { + "codecId": "pg/float8@1", + "kind": "scalar" + } + }, + "title": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "userId": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + } + }, + "relations": { + "user": { + "cardinality": "N:1", + "on": { + "localFields": ["userId"], + "targetFields": ["id"] + }, + "to": "User" + } + }, + "storage": { + "fields": { + "id": { + "column": "id" + }, + "rating": { + "column": "rating" + }, + "title": { + "column": "title" + }, + "userId": { + "column": "userId" + } + }, + "table": "post" + } + }, + "User": { + "fields": { + "age": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "createdAt": { + "nullable": false, + "type": { + "codecId": "pg/timestamptz@1", + "kind": "scalar" + } + }, + "email": { + "nullable": false, + "type": { + "codecId": "pg/text@1", + "kind": "scalar" + } + }, + "embedding": { + "nullable": true, + "type": { + "codecId": "pg/vector@1", + "kind": "scalar" + } + }, + "id": { + "nullable": false, + "type": { + "codecId": "pg/int4@1", + "kind": "scalar" + } + }, + "isActive": { + "nullable": false, + "type": { + "codecId": "pg/bool@1", + "kind": "scalar" + } + }, + "profile": { + "nullable": true, + "type": { + "codecId": "pg/jsonb@1", + "kind": "scalar" + } + }, + "score": { + "nullable": true, + "type": { + "codecId": "pg/float8@1", + "kind": "scalar" + } + } + }, + "relations": {}, + "storage": { + "fields": { + "age": { + "column": "age" + }, + "createdAt": { + "column": "createdAt" + }, + "email": { + "column": "email" + }, + "embedding": { + "column": "embedding" + }, + "id": { + "column": "id" + }, + "isActive": { + "column": "isActive" + }, + "profile": { + "column": "profile" + }, + "score": { + "column": "score" + } + }, + "table": "user" + } + } + }, + "storage": { + "storageHash": "sha256:615d7f6e37b15d66c64b8e500ee79877eecae9bb484d48b429f3b3c1d1e21051", + "tables": { + "post": { + "columns": { + "id": { + "codecId": "pg/int4@1", + "default": { + "expression": "autoincrement()", + "kind": "function" + }, + "nativeType": "int4", + "nullable": false + }, + "rating": { + "codecId": "pg/float8@1", + "nativeType": "float8", + "nullable": true + }, + "title": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "userId": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + } + }, + "foreignKeys": [ + { + "columns": ["userId"], + "constraint": true, + "index": true, + "onDelete": "cascade", + "onUpdate": "cascade", + "references": { + "columns": ["id"], + "table": "user" + } + } + ], + "indexes": [], + "primaryKey": { + "columns": ["id"] + }, + "uniques": [] + }, + "user": { + "columns": { + "age": { + "codecId": "pg/int4@1", + "nativeType": "int4", + "nullable": false + }, + "createdAt": { + "codecId": "pg/timestamptz@1", + "default": { + "expression": "now()", + "kind": "function" + }, + "nativeType": "timestamptz", + "nullable": false + }, + "email": { + "codecId": "pg/text@1", + "nativeType": "text", + "nullable": false + }, + "embedding": { + "codecId": "pg/vector@1", + "nativeType": "vector", + "nullable": true, + "typeRef": "Embedding" + }, + "id": { + "codecId": "pg/int4@1", + "default": { + "expression": "autoincrement()", + "kind": "function" + }, + "nativeType": "int4", + "nullable": false + }, + "isActive": { + "codecId": "pg/bool@1", + "default": { + "kind": "literal", + "value": true + }, + "nativeType": "bool", + "nullable": false + }, + "profile": { + "codecId": "pg/jsonb@1", + "nativeType": "jsonb", + "nullable": true + }, + "score": { + "codecId": "pg/float8@1", + "nativeType": "float8", + "nullable": true + } + }, + "foreignKeys": [], + "indexes": [], + "primaryKey": { + "columns": ["id"] + }, + "uniques": [ + { + "columns": ["email"] + } + ] + } + }, + "types": { + "Embedding": { + "codecId": "pg/vector@1", + "nativeType": "vector", + "typeParams": { + "length": 1536 + } + } + } + }, + "capabilities": { + "postgres": { + "jsonAgg": true, + "lateral": true, + "limit": true, + "orderBy": true, + "pgvector.cosine": true, + "returning": true + }, + "sql": { + "defaultInInsert": true, + "enums": true, + "returning": true + } + }, + "extensionPacks": { + "pgvector": { + "capabilities": { + "postgres": { + "pgvector.cosine": true + } + }, + "familyId": "sql", + "id": "pgvector", + "kind": "extension", + "targetId": "postgres", + "types": { + "codecTypes": { + "import": { + "alias": "PgVectorTypes", + "named": "CodecTypes", + "package": "@prisma-next/extension-pgvector/codec-types" + }, + "typeImports": [ + { + "alias": "Vector", + "named": "Vector", + "package": "@prisma-next/extension-pgvector/codec-types" + } + ] + }, + "operationTypes": { + "import": { + "alias": "PgVectorOperationTypes", + "named": "OperationTypes", + "package": "@prisma-next/extension-pgvector/operation-types" + } + }, + "queryOperationTypes": { + "import": { + "alias": "PgVectorQueryOperationTypes", + "named": "QueryOperationTypes", + "package": "@prisma-next/extension-pgvector/operation-types" + } + }, + "storage": [ + { + "familyId": "sql", + "nativeType": "vector", + "targetId": "postgres", + "typeId": "pg/vector@1" + } + ] + }, + "version": "0.0.1" + } + }, + "meta": {}, + "_generated": { + "warning": "⚠️ GENERATED FILE - DO NOT EDIT", + "message": "This file is automatically generated by \"prisma-next contract emit\".", + "regenerate": "To regenerate, run: prisma-next contract emit" + } +} diff --git a/test/integration/test/authoring/parity/callback-mode-scalars/packs.ts b/test/integration/test/authoring/parity/callback-mode-scalars/packs.ts new file mode 100644 index 0000000000..03bd7070f6 --- /dev/null +++ b/test/integration/test/authoring/parity/callback-mode-scalars/packs.ts @@ -0,0 +1,3 @@ +import pgvector from '@prisma-next/extension-pgvector/control'; + +export const extensionPacks = [pgvector] as const; diff --git a/test/integration/test/authoring/parity/callback-mode-scalars/schema.prisma b/test/integration/test/authoring/parity/callback-mode-scalars/schema.prisma new file mode 100644 index 0000000000..62dde07ca0 --- /dev/null +++ b/test/integration/test/authoring/parity/callback-mode-scalars/schema.prisma @@ -0,0 +1,22 @@ +types { + Embedding = pgvector.Vector(1536) +} + +model User { + id Int @id @default(autoincrement()) + email String @unique + age Int + isActive Boolean @default(true) + score Float? + profile Json? + embedding Embedding? + createdAt DateTime @default(now()) +} + +model Post { + id Int @id @default(autoincrement()) + userId Int + title String + rating Float? + user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade) +} diff --git a/test/integration/test/contract-builder.types.test-d.ts b/test/integration/test/contract-builder.types.test-d.ts index aa30730907..6e8d3eb93c 100644 --- a/test/integration/test/contract-builder.types.test-d.ts +++ b/test/integration/test/contract-builder.types.test-d.ts @@ -187,9 +187,15 @@ test('integrated callback authoring exposes composition-shaped type helpers', () models: { User: model('User', { fields: { - id: field.id.uuidv7(), + id: field.int().defaultSql('autoincrement()').id(), + email: field.text().unique(), + age: field.int(), + isActive: field.boolean().default(true), + score: field.float().optional(), + profile: field.json().optional(), role: field.namedType(Role), embedding: field.namedType(Embedding).optional(), + createdAt: field.createdAt(), }, }).sql({ table: 'user', @@ -204,6 +210,15 @@ test('integrated callback authoring exposes composition-shaped type helpers', () expectTypeOf().toEqualTypeOf<'Role' | 'Embedding'>(); expectTypeOf().toEqualTypeOf<'pg/enum@1'>(); expectTypeOf().toEqualTypeOf<'pg/vector@1'>(); + expectTypeOf(contract.storage.tables.user.columns.id.codecId).toEqualTypeOf<'pg/int4@1'>(); + expectTypeOf(contract.storage.tables.user.columns.email.codecId).toEqualTypeOf<'pg/text@1'>(); + expectTypeOf(contract.storage.tables.user.columns.age.codecId).toEqualTypeOf<'pg/int4@1'>(); + expectTypeOf(contract.storage.tables.user.columns.isActive.codecId).toEqualTypeOf<'pg/bool@1'>(); + expectTypeOf(contract.storage.tables.user.columns.score.codecId).toEqualTypeOf<'pg/float8@1'>(); + expectTypeOf(contract.storage.tables.user.columns.profile.codecId).toEqualTypeOf<'pg/jsonb@1'>(); + expectTypeOf( + contract.storage.tables.user.columns.createdAt.codecId, + ).toEqualTypeOf<'pg/timestamptz@1'>(); expectTypeOf(contract.storage.tables.user.columns.role.typeRef).toEqualTypeOf<'Role'>(); expectTypeOf(contract.storage.tables.user.columns.embedding.typeRef).toEqualTypeOf<'Embedding'>(); });