diff --git a/apps/telemetry-backend/src/prisma/contract.d.ts b/apps/telemetry-backend/src/prisma/contract.d.ts index 92e46d2c69..d6dd1235c5 100644 --- a/apps/telemetry-backend/src/prisma/contract.d.ts +++ b/apps/telemetry-backend/src/prisma/contract.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated by 'prisma-next contract emit'. // To regenerate, run: prisma-next contract emit import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -38,7 +39,8 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps; +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/examples/bundle-size/src/postgres/generated/contract.d.ts b/examples/bundle-size/src/postgres/generated/contract.d.ts index 727d2b5de2..54cccf1ec5 100644 --- a/examples/bundle-size/src/postgres/generated/contract.d.ts +++ b/examples/bundle-size/src/postgres/generated/contract.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated by 'prisma-next contract emit'. // To regenerate, run: prisma-next contract emit import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -39,7 +40,8 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps; +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/examples/multi-extension-monorepo/app/src/contract.d.ts b/examples/multi-extension-monorepo/app/src/contract.d.ts index e880085c73..d79c8b844d 100644 --- a/examples/multi-extension-monorepo/app/src/contract.d.ts +++ b/examples/multi-extension-monorepo/app/src/contract.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated by 'prisma-next contract emit'. // To regenerate, run: prisma-next contract emit import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -38,7 +39,8 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps; +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/examples/multi-extension-monorepo/packages/audit/src/contract.d.ts b/examples/multi-extension-monorepo/packages/audit/src/contract.d.ts index 4216ebd3e4..79404bc977 100644 --- a/examples/multi-extension-monorepo/packages/audit/src/contract.d.ts +++ b/examples/multi-extension-monorepo/packages/audit/src/contract.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated by 'prisma-next contract emit'. // To regenerate, run: prisma-next contract emit import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -38,7 +39,8 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps; +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/examples/multi-extension-monorepo/packages/feature-flags/src/contract.d.ts b/examples/multi-extension-monorepo/packages/feature-flags/src/contract.d.ts index 658ebde742..368936adf9 100644 --- a/examples/multi-extension-monorepo/packages/feature-flags/src/contract.d.ts +++ b/examples/multi-extension-monorepo/packages/feature-flags/src/contract.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated by 'prisma-next contract emit'. // To regenerate, run: prisma-next contract emit import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -38,7 +39,8 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps; +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/examples/paradedb-demo/src/prisma/contract.d.ts b/examples/paradedb-demo/src/prisma/contract.d.ts index bfe926d8ac..e0743c6fe1 100644 --- a/examples/paradedb-demo/src/prisma/contract.d.ts +++ b/examples/paradedb-demo/src/prisma/contract.d.ts @@ -3,6 +3,7 @@ // To regenerate, run: prisma-next contract emit import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; import type { QueryOperationTypes as ParadeDbQueryOperationTypes } from '@prisma-next/extension-paradedb/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -39,7 +40,8 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps & +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps & ParadeDbQueryOperationTypes; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] diff --git a/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts b/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts index 4c8181e9c8..bc7b3cb622 100644 --- a/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts +++ b/examples/prisma-next-cloudflare-worker/src/prisma/contract.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated by 'prisma-next contract emit'. // To regenerate, run: prisma-next contract emit import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -39,7 +40,8 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps; +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts b/examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts index 5255ec0850..888fff2949 100644 --- a/examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts +++ b/examples/prisma-next-demo-sqlite/src/prisma/contract.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated by 'prisma-next contract emit'. // To regenerate, run: prisma-next contract emit import type { CodecTypes as SqliteTypes } from '@prisma-next/adapter-sqlite/codec-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { ContractWithTypeMaps, @@ -25,7 +26,7 @@ export type ProfileHash = export type CodecTypes = SqliteTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = Record; +export type QueryOperationTypes = SqlFamilyQueryOperationTypes; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/examples/prisma-next-demo/src/prisma-no-emit/context.ts b/examples/prisma-next-demo/src/prisma-no-emit/context.ts index 40302a6772..0d85001612 100644 --- a/examples/prisma-next-demo/src/prisma-no-emit/context.ts +++ b/examples/prisma-next-demo/src/prisma-no-emit/context.ts @@ -2,6 +2,7 @@ import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; import postgresDriver from '@prisma-next/driver-postgres/runtime'; import pgvector from '@prisma-next/extension-pgvector/runtime'; import { SqlContractSerializer } from '@prisma-next/family-sql/ir'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { sql as sqlBuilder } from '@prisma-next/sql-builder/runtime'; import { orm } from '@prisma-next/sql-orm-client'; import type { Runtime } from '@prisma-next/sql-runtime'; @@ -11,6 +12,7 @@ import { contract } from '../../prisma/contract'; import { PostCollection, UserCollection } from '../orm-client/collections'; export const stack = createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter, driver: postgresDriver, diff --git a/examples/prisma-next-demo/src/prisma/contract.d.ts b/examples/prisma-next-demo/src/prisma/contract.d.ts index 4e4b67e984..f5c0efc7ba 100644 --- a/examples/prisma-next-demo/src/prisma/contract.d.ts +++ b/examples/prisma-next-demo/src/prisma/contract.d.ts @@ -7,6 +7,7 @@ import type { Vector, } from '@prisma-next/extension-pgvector/codec-types'; import type { QueryOperationTypes as PgVectorQueryOperationTypes } from '@prisma-next/extension-pgvector/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -44,7 +45,8 @@ export type ProfileHash = export type CodecTypes = PgTypes & PgVectorTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps & +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps & PgVectorQueryOperationTypes; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] diff --git a/examples/prisma-next-demo/src/queries/cross-author-similarity.ts b/examples/prisma-next-demo/src/queries/cross-author-similarity.ts index f4bdeda98d..dadf6cd82c 100644 --- a/examples/prisma-next-demo/src/queries/cross-author-similarity.ts +++ b/examples/prisma-next-demo/src/queries/cross-author-similarity.ts @@ -33,7 +33,7 @@ import { db } from '../prisma/db'; * * Features exercised: * 1. Self-join via `.as()` aliasing of the same table (`post` aliased as `p1` and `p2`). - * 2. INNER JOIN with a non-equality predicate (`ne(p1.userId, p2.userId)`). + * 2. INNER JOIN with a non-equality predicate (`neq(p1.userId, p2.userId)`). * 3. pgvector `cosineDistance` called with two column references from two aliases — in the * SELECT projection and in the ORDER BY. * 4. Typed result row inferred from the SELECT projection, mixing columns from both aliases. @@ -41,7 +41,7 @@ import { db } from '../prisma/db'; export async function crossAuthorSimilarity(limit = 10, runtime?: Runtime) { const plan = db.sql.post .as('p1') - .innerJoin(db.sql.post.as('p2'), (f, fns) => fns.ne(f.p1.userId, f.p2.userId)) + .innerJoin(db.sql.post.as('p2'), (f, fns) => fns.neq(f.p1.userId, f.p2.userId)) .select((f, fns) => ({ postAId: f.p1.id, postATitle: f.p1.title, @@ -51,7 +51,7 @@ export async function crossAuthorSimilarity(limit = 10, runtime?: Runtime) { postBUserId: f.p2.userId, distance: fns.cosineDistance(f.p1.embedding, f.p2.embedding), })) - .where((f, fns) => fns.and(fns.ne(f.p1.embedding, null), fns.ne(f.p2.embedding, null))) + .where((f, fns) => fns.and(fns.neq(f.p1.embedding, null), fns.neq(f.p2.embedding, null))) .orderBy((f, fns) => fns.cosineDistance(f.p1.embedding, f.p2.embedding), { direction: 'asc', }) diff --git a/examples/prisma-next-postgis-demo/src/prisma/contract.d.ts b/examples/prisma-next-postgis-demo/src/prisma/contract.d.ts index 3a8ea7fcf9..557c3dd40c 100644 --- a/examples/prisma-next-postgis-demo/src/prisma/contract.d.ts +++ b/examples/prisma-next-postgis-demo/src/prisma/contract.d.ts @@ -7,6 +7,7 @@ import type { Geometry, } from '@prisma-next/extension-postgis/codec-types'; import type { QueryOperationTypes as PostgisQueryOperationTypes } from '@prisma-next/extension-postgis/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -44,7 +45,8 @@ export type ProfileHash = export type CodecTypes = PgTypes & PostgisTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps & +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps & PostgisQueryOperationTypes; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] diff --git a/examples/react-router-demo/src/prisma/contract.d.ts b/examples/react-router-demo/src/prisma/contract.d.ts index d50ecf4430..dc898a5a84 100644 --- a/examples/react-router-demo/src/prisma/contract.d.ts +++ b/examples/react-router-demo/src/prisma/contract.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated by 'prisma-next contract emit'. // To regenerate, run: prisma-next contract emit import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -39,7 +40,8 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps; +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/packages/1-framework/1-core/operations/src/index.ts b/packages/1-framework/1-core/operations/src/index.ts index d3f9eb269a..66ae020840 100644 --- a/packages/1-framework/1-core/operations/src/index.ts +++ b/packages/1-framework/1-core/operations/src/index.ts @@ -10,8 +10,9 @@ export interface ReturnSpec { } export type SelfSpec = - | { readonly codecId: string; readonly traits?: never } - | { readonly traits: readonly string[]; readonly codecId?: never }; + | { readonly codecId: string; readonly traits?: never; readonly any?: never } + | { readonly traits: readonly string[]; readonly codecId?: never; readonly any?: never } + | { readonly any: true; readonly codecId?: never; readonly traits?: never }; export interface OperationEntry { readonly self?: SelfSpec; @@ -42,11 +43,15 @@ export function createOperationRegistry< if (descriptor.self) { const hasCodecId = descriptor.self.codecId !== undefined; const hasTraits = descriptor.self.traits !== undefined && descriptor.self.traits.length > 0; - if (!hasCodecId && !hasTraits) { - throw new Error(`Operation "${name}" self has neither codecId nor traits`); + const hasAny = descriptor.self.any === true; + if (!hasCodecId && !hasTraits && !hasAny) { + throw new Error(`Operation "${name}" self has none of codecId, traits, or any`); } if (hasCodecId && hasTraits) { - throw new Error(`Operation "${name}" self has both codecId and traits`); + throw new Error(`Operation "${name}" self combines codecId and traits`); + } + if (hasAny && (hasCodecId || hasTraits)) { + throw new Error(`Operation "${name}" self combines any with codecId or traits`); } } operations[name] = descriptor; diff --git a/packages/1-framework/1-core/operations/test/operations-registry.test.ts b/packages/1-framework/1-core/operations/test/operations-registry.test.ts index c3ad0bcab6..2a12f3ef4b 100644 --- a/packages/1-framework/1-core/operations/test/operations-registry.test.ts +++ b/packages/1-framework/1-core/operations/test/operations-registry.test.ts @@ -48,16 +48,16 @@ describe('OperationRegistry', () => { ); }); - it('throws when self has neither codecId nor traits', () => { + it('throws when self has none of codecId, traits, or any', () => { const registry = createOperationRegistry(); expect(() => registry.register('bad', { - // @ts-expect-error — SelfSpec requires codecId or traits + // @ts-expect-error — SelfSpec requires codecId, traits, or any self: {}, impl: noopImpl, }), - ).toThrow('Operation "bad" self has neither codecId nor traits'); + ).toThrow('Operation "bad" self has none of codecId, traits, or any'); }); it('throws when self has an empty traits array', () => { @@ -68,19 +68,56 @@ describe('OperationRegistry', () => { self: { traits: [] }, impl: noopImpl, }), - ).toThrow('Operation "bad" self has neither codecId nor traits'); + ).toThrow('Operation "bad" self has none of codecId, traits, or any'); }); - it('throws when self has both codecId and traits', () => { + it('throws when self combines codecId and traits', () => { const registry = createOperationRegistry(); expect(() => registry.register('bad', { - // @ts-expect-error — SelfSpec disallows both codecId and traits + // @ts-expect-error — SelfSpec disallows combining codecId and traits self: { codecId: 'pg/text@1', traits: ['textual'] }, impl: noopImpl, }), - ).toThrow('Operation "bad" self has both codecId and traits'); + ).toThrow('Operation "bad" self combines codecId and traits'); + }); + + it('accepts self with any: true', () => { + const registry = createOperationRegistry(); + + expect(() => + registry.register( + 'fine', + descriptor({ + self: { any: true }, + }), + ), + ).not.toThrow(); + }); + + it('throws when self combines any with codecId', () => { + const registry = createOperationRegistry(); + + expect(() => + registry.register('bad', { + // @ts-expect-error — SelfSpec disallows combining any with codecId + self: { any: true, codecId: 'pg/text@1' }, + impl: noopImpl, + }), + ).toThrow('Operation "bad" self combines any with codecId or traits'); + }); + + it('throws when self combines any with traits', () => { + const registry = createOperationRegistry(); + + expect(() => + registry.register('bad', { + // @ts-expect-error — SelfSpec disallows combining any with traits + self: { any: true, traits: ['textual'] }, + impl: noopImpl, + }), + ).toThrow('Operation "bad" self combines any with codecId or traits'); }); it('accepts trait-only self', () => { diff --git a/packages/2-sql/1-core/contract/src/types.ts b/packages/2-sql/1-core/contract/src/types.ts index ed0ca1d233..f3ef10e75b 100644 --- a/packages/2-sql/1-core/contract/src/types.ts +++ b/packages/2-sql/1-core/contract/src/types.ts @@ -94,12 +94,18 @@ export type CodecTypesOf = [T] extends [never] * Dispatch hint identifying the first-argument target of an operation. * * Used by ORM column helpers to decide whether an operation is reachable on a - * field. Either names a concrete codec identity or a set of capability traits - * that the field's codec must carry. + * field. Either names a concrete codec identity, a set of capability traits + * that the field's codec must carry, or `any: true` meaning the operation + * applies to every codec irrespective of traits. + * + * Keep arms in lock-step with `SelfSpec` in `@prisma-next/operations` (same + * arms, same order). The runtime registry validates the same exactly-one-set + * invariant the type-level discriminated union encodes. */ export type QueryOperationSelfSpec = - | { readonly codecId: string; readonly traits?: never } - | { readonly traits: readonly CodecTrait[]; readonly codecId?: never }; + | { readonly codecId: string; readonly traits?: never; readonly any?: never } + | { readonly traits: readonly CodecTrait[]; readonly codecId?: never; readonly any?: never } + | { readonly any: true; readonly codecId?: never; readonly traits?: never }; /** * Structural shape an operation's impl must return: any value carrying a diff --git a/packages/2-sql/4-lanes/relational-core/src/expression.ts b/packages/2-sql/4-lanes/relational-core/src/expression.ts index a83ef43eb0..2928216e4e 100644 --- a/packages/2-sql/4-lanes/relational-core/src/expression.ts +++ b/packages/2-sql/4-lanes/relational-core/src/expression.ts @@ -3,7 +3,7 @@ import type { ParamSpec } from '@prisma-next/operations'; import type { QueryOperationReturn } from '@prisma-next/sql-contract/types'; import type { SqlLoweringSpec } from '@prisma-next/sql-operations'; import type { CodecRef } from './ast/codec-types'; -import type { AnyExpression as AstExpression, RawSqlLiteral } from './ast/types'; +import type { AnyExpression as AstExpression, RawSqlLiteral, SelectAst } from './ast/types'; import { OperationExpr, ParamRef, RawExpr } from './ast/types'; export type ScopeField = { @@ -25,6 +25,32 @@ export type Expression = QueryOperationReturn & { buildAst(): AstExpression; }; +/** + * Brand symbol that distinguishes `Subquery` from any other shape + * exposing `buildAst()` + `getRowFields()`. Declared as a `unique symbol` so + * its identity is preserved across the workspace; only types referencing + * this exact symbol satisfy `Subquery`. + * + * Lives in `sql-relational-core` (rather than in `sql-builder` where it was + * originally introduced) so non-builder consumers — notably the SQL family + * operation type twin — can reference `Subquery` without a workspace edge + * back to `sql-builder`. + */ +export declare const SubqueryMarker: unique symbol; + +/** + * A SQL subquery typed by the row shape it produces. Built by the + * sql-builder `SelectQuery` / `GroupedQuery` runtime classes (which stamp + * `[SubqueryMarker]` via a `declare readonly` property); referenced by + * operation surfaces that accept subqueries (`fns.in`, `fns.notIn`, + * `fns.exists`, `fns.notExists`, `lateralJoin`). + */ +export type Subquery> = { + [SubqueryMarker]: RowType; + buildAst(): SelectAst; + getRowFields(): Record; +}; + type CodecIdsWithTrait< CT extends Record, RequiredTraits extends readonly string[], diff --git a/packages/2-sql/4-lanes/sql-builder/src/expression.ts b/packages/2-sql/4-lanes/sql-builder/src/expression.ts index 0070349c28..54e43271da 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/expression.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/expression.ts @@ -5,7 +5,7 @@ import type { RawSqlTag, TraitExpression, } from '@prisma-next/sql-relational-core/expression'; -import type { Expand, QueryContext, Scope, ScopeField, ScopeTable, Subquery } from './scope'; +import type { Expand, QueryContext, Scope, ScopeField, ScopeTable } from './scope'; export type { CodecExpression, Expression, RawSqlTag, TraitExpression }; @@ -60,64 +60,18 @@ type DeriveExtFunctions = { [K in keyof OT]: OT[K]['impl']; }; -export type BuiltinFunctions> = { - eq: ( - a: CodecExpression | null, - b: CodecExpression | null, - ) => Expression; - ne: ( - a: CodecExpression | null, - b: CodecExpression | null, - ) => Expression; - gt: ( - a: CodecExpression, - b: CodecExpression, - ) => Expression; - gte: ( - a: CodecExpression, - b: CodecExpression, - ) => Expression; - lt: ( - a: CodecExpression, - b: CodecExpression, - ) => Expression; - lte: ( - a: CodecExpression, - b: CodecExpression, - ) => Expression; - and: (...ands: CodecExpression<'pg/bool@1', boolean, CT>[]) => Expression; - or: (...ors: CodecExpression<'pg/bool@1', boolean, CT>[]) => Expression; - - exists: (subquery: Subquery>) => Expression; - notExists: (subquery: Subquery>) => Expression; - - in: { - ( - expr: Expression<{ codecId: CodecId; nullable: boolean }>, - subquery: Subquery>, - ): Expression; - ( - expr: Expression<{ codecId: CodecId; nullable: boolean }>, - values: Array>, - ): Expression; - }; - - notIn: { - ( - expr: Expression<{ codecId: CodecId; nullable: boolean }>, - subquery: Subquery>, - ): Expression; - ( - expr: Expression<{ codecId: CodecId; nullable: boolean }>, - values: Array>, - ): Expression; - }; - +// `BuiltinFunctions` was deleted in slice 3 of the unify-query-operations +// project: every trait-gated builtin (eq, neq, in, notIn, gt, gte, lt, lte, +// like, isNull, isNotNull, and, or, exists, notExists) now sources from the +// SQL-family registry via `DeriveExtFunctions`. +// +// `raw` is preserved as a top-level slot on `Functions` because its +// runtime impl depends on the adapter-supplied `RawCodecInferer` and so +// is wired through `createFunctions(operations, rawCodecInferer)` rather +// than registered as a family operation. +export type Functions = { readonly raw: RawSqlTag; -}; - -export type Functions = BuiltinFunctions & - DeriveExtFunctions; +} & DeriveExtFunctions; export type CountField = { codecId: 'pg/int8@1'; nullable: false }; diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts index f1ed884501..6693098633 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts @@ -1,130 +1,16 @@ import type { SqlOperationEntry } from '@prisma-next/sql-operations'; -import { - AggregateExpr, - AndExpr, - type AnyExpression as AstExpression, - BinaryExpr, - type BinaryOp, - type CodecRef, - ExistsExpr, - ListExpression, - LiteralExpr, - NullCheckExpr, - OrExpr, - SubqueryExpr, -} from '@prisma-next/sql-relational-core/ast'; +import { AggregateExpr } from '@prisma-next/sql-relational-core/ast'; import type { RawCodecInferer } from '@prisma-next/sql-relational-core/expression'; -import { codecOf, createRawSql, toExpr } from '@prisma-next/sql-relational-core/expression'; +import { createRawSql } from '@prisma-next/sql-relational-core/expression'; import type { AggregateFunctions, AggregateOnlyFunctions, - BooleanCodecType, - BuiltinFunctions, - CodecExpression, Expression, Functions, } from '../expression'; -import type { QueryContext, ScopeField, Subquery } from '../scope'; +import type { QueryContext, ScopeField } from '../scope'; import { ExpressionImpl } from './expression-impl'; -type CodecTypes = Record; -// Runtime-level ExprOrVal — accepts any codec, any nullability. Concrete codec typing lives on the public BuiltinFunctions surface in `../expression`. -type ExprOrVal = CodecExpression< - CodecId, - N, - CodecTypes ->; - -const BOOL_FIELD: BooleanCodecType = { codecId: 'pg/bool@1', nullable: false }; - -const resolve = toExpr; - -/** - * Resolve a binary-comparison operand into an AST expression, threading the column-bound side's {@link CodecRef} to the raw-value side. - * - * For `fns.eq(f.email, 'alice@example.com')`, `f.email` is the column-bound expression carrying a `ColumnRef` AST and a `CodecRef` derived from contract storage; the raw string operand has no codec context. By deriving the codec context from the column-bound side and forwarding it via `toExpr(value, codec)`, the resulting `ParamRef` carries the `CodecRef` that encode-side dispatch needs to materialise the per-instance codec for parameterized codec ids (`vector(1024)` vs. `vector(1536)`). - */ -function resolveOperand(operand: ExprOrVal, otherCodec?: CodecRef): AstExpression { - if (isExpressionLike(operand)) return operand.buildAst(); - return toExpr(operand, otherCodec); -} - -function isExpressionLike( - value: unknown, -): value is { buildAst: () => AstExpression; returnType?: { codecId: string } } { - return ( - typeof value === 'object' && - value !== null && - 'buildAst' in value && - typeof (value as { buildAst: unknown }).buildAst === 'function' - ); -} - -/** - * Resolves an Expression via `buildAst()`, or wraps a raw value as a `LiteralExpr` — an SQL literal inlined into the query text, not a bound parameter. - * - * Used for `and` / `or` operands. The usual operand is an `Expression` (e.g. the result of `fns.eq`), which this function passes through by calling `buildAst()`. The only time the raw-value branch fires is when the caller writes `fns.and(true, x)` or similar — inlining `TRUE`/`FALSE` literals lets the SQL planner statically simplify `TRUE AND x` to `x`, which it cannot do for an opaque `ParamRef`. - */ -function toLiteralExpr(value: unknown): AstExpression { - if ( - typeof value === 'object' && - value !== null && - 'buildAst' in value && - typeof (value as { buildAst: unknown }).buildAst === 'function' - ) { - return (value as { buildAst(): AstExpression }).buildAst(); - } - return new LiteralExpr(value); -} - -function boolExpr(astNode: AstExpression): ExpressionImpl { - return new ExpressionImpl(astNode, BOOL_FIELD); -} - -function binaryWithSharedCodec( - a: ExprOrVal, - b: ExprOrVal, - build: (left: AstExpression, right: AstExpression) => AstExpression, -): AstExpression { - const aCodec = codecOf(a); - const bCodec = codecOf(b); - const left = resolveOperand(a, bCodec); - const right = resolveOperand(b, aCodec); - return build(left, right); -} - -function eq(a: ExprOrVal, b: ExprOrVal): ExpressionImpl { - if (b === null) return boolExpr(NullCheckExpr.isNull(resolve(a))); - if (a === null) return boolExpr(NullCheckExpr.isNull(resolve(b))); - return boolExpr(binaryWithSharedCodec(a, b, (l, r) => new BinaryExpr('eq', l, r))); -} - -function ne(a: ExprOrVal, b: ExprOrVal): ExpressionImpl { - if (b === null) return boolExpr(NullCheckExpr.isNotNull(resolve(a))); - if (a === null) return boolExpr(NullCheckExpr.isNotNull(resolve(b))); - return boolExpr(binaryWithSharedCodec(a, b, (l, r) => new BinaryExpr('neq', l, r))); -} - -function comparison(a: ExprOrVal, b: ExprOrVal, op: BinaryOp): ExpressionImpl { - return boolExpr(binaryWithSharedCodec(a, b, (l, r) => new BinaryExpr(op, l, r))); -} - -function inOrNotIn( - expr: Expression, - valuesOrSubquery: Subquery> | ExprOrVal[], - op: 'in' | 'notIn', -): ExpressionImpl { - const left = expr.buildAst(); - const leftCodec = codecOf(expr); - const binaryFn = op === 'in' ? BinaryExpr.in : BinaryExpr.notIn; - - if (Array.isArray(valuesOrSubquery)) { - const refs = valuesOrSubquery.map((v) => resolveOperand(v, leftCodec)); - return boolExpr(binaryFn(left, ListExpression.of(refs))); - } - return boolExpr(binaryFn(left, SubqueryExpr.of(valuesOrSubquery.buildAst()))); -} - function numericAgg( fn: 'sum' | 'avg' | 'min' | 'max', expr: Expression, @@ -135,34 +21,6 @@ function numericAgg( }); } -function createBuiltinFunctions(rawCodecInferer: RawCodecInferer) { - return { - eq: (a: ExprOrVal, b: ExprOrVal) => eq(a, b), - ne: (a: ExprOrVal, b: ExprOrVal) => ne(a, b), - gt: (a: ExprOrVal, b: ExprOrVal) => comparison(a, b, 'gt'), - gte: (a: ExprOrVal, b: ExprOrVal) => comparison(a, b, 'gte'), - lt: (a: ExprOrVal, b: ExprOrVal) => comparison(a, b, 'lt'), - lte: (a: ExprOrVal, b: ExprOrVal) => comparison(a, b, 'lte'), - and: (...exprs: ExprOrVal<'pg/bool@1', boolean>[]) => - boolExpr(AndExpr.of(exprs.map(toLiteralExpr))), - or: (...exprs: ExprOrVal<'pg/bool@1', boolean>[]) => - boolExpr(OrExpr.of(exprs.map(toLiteralExpr))), - exists: (subquery: Subquery>) => - boolExpr(ExistsExpr.exists(subquery.buildAst())), - notExists: (subquery: Subquery>) => - boolExpr(ExistsExpr.notExists(subquery.buildAst())), - in: ( - expr: Expression, - valuesOrSubquery: Subquery> | ExprOrVal[], - ) => inOrNotIn(expr, valuesOrSubquery, 'in'), - notIn: ( - expr: Expression, - valuesOrSubquery: Subquery> | ExprOrVal[], - ) => inOrNotIn(expr, valuesOrSubquery, 'notIn'), - raw: createRawSql(rawCodecInferer), - } satisfies BuiltinFunctions; -} - function createAggregateOnlyFunctions() { return { count: (expr?: Expression) => { @@ -183,17 +41,17 @@ export function createFunctions( operations: Readonly>, rawCodecInferer: RawCodecInferer, ): Functions { - const builtins = createBuiltinFunctions(rawCodecInferer); - + // `raw` is the only builtin left on `Functions` after slice 3 of the + // unify-query-operations project — every other former builtin + // (`eq`/`neq`/`in`/etc.) now sources from the SQL-family registry via + // `operations`. `raw` stays a hardcoded slot because its impl depends + // on the adapter-supplied `RawCodecInferer`, which is not a static + // family contribution. + const raw = createRawSql(rawCodecInferer); return new Proxy({} as Functions, { get(_target, prop: string) { - if (Object.hasOwn(builtins, prop)) { - return (builtins as Record)[prop]; - } - - const op = operations[prop]; - if (op) return op.impl; - return undefined; + if (prop === 'raw') return raw; + return operations[prop]?.impl; }, }); } diff --git a/packages/2-sql/4-lanes/sql-builder/src/runtime/query-impl.ts b/packages/2-sql/4-lanes/sql-builder/src/runtime/query-impl.ts index b3fb55284a..19ea5a241c 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/runtime/query-impl.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/runtime/query-impl.ts @@ -5,7 +5,11 @@ import type { } from '@prisma-next/framework-components/runtime'; import { assertAnnotationsApplicable } from '@prisma-next/framework-components/runtime'; import { DerivedTableSource, type SelectAst } from '@prisma-next/sql-relational-core/ast'; -import { toExpr } from '@prisma-next/sql-relational-core/expression'; +import { + // biome-ignore lint/correctness/noUnusedImports: used in `declare` property + type SubqueryMarker, + toExpr, +} from '@prisma-next/sql-relational-core/expression'; import type { SqlQueryPlan } from '@prisma-next/sql-relational-core/plan'; import type { AggregateFunctions, @@ -28,8 +32,6 @@ import type { QueryContext, Scope, ScopeField, - // biome-ignore lint/correctness/noUnusedImports: used in `declare` property - SubqueryMarker, } from '../scope'; import type { GroupedQuery } from '../types/grouped-query'; import type { SelectQuery } from '../types/select-query'; diff --git a/packages/2-sql/4-lanes/sql-builder/src/scope.ts b/packages/2-sql/4-lanes/sql-builder/src/scope.ts index 97b30e5d67..b130d8bae2 100644 --- a/packages/2-sql/4-lanes/sql-builder/src/scope.ts +++ b/packages/2-sql/4-lanes/sql-builder/src/scope.ts @@ -1,8 +1,18 @@ import type { QueryOperationTypesBase, StorageTable } from '@prisma-next/sql-contract/types'; -import type { AnyFromSource, SelectAst } from '@prisma-next/sql-relational-core/ast'; +import type { AnyFromSource } from '@prisma-next/sql-relational-core/ast'; import type { CodecTypesBase, ScopeField } from '@prisma-next/sql-relational-core/expression'; export type { ScopeField }; +// `Subquery` was moved to `sql-relational-core` to break a workspace cycle +// (`family-sql` needed `Subquery` for its operation type twin without +// depending on `sql-builder`). Re-exported here so existing +// `import { Subquery } from '@prisma-next/sql-builder/types'` callsites +// continue to resolve. `SubqueryMarker` is not re-exported — in-package +// consumers (`runtime/query-impl.ts`) import it directly from +// `@prisma-next/sql-relational-core/expression` because it is a +// `declare`d value symbol and a value re-export would emit a runtime +// binding that the upstream module does not provide. +export type { Subquery } from '@prisma-next/sql-relational-core/expression'; export type TraitField = { traits: readonly string[]; nullable: boolean }; export type FieldSpec = ScopeField | TraitField; @@ -12,7 +22,6 @@ export type GatedMethod = Capabilities extends R : never; export declare const JoinOuterScope: unique symbol; -export declare const SubqueryMarker: unique symbol; export type Expand = { [K in keyof T]: T[K] } & unknown; export type EmptyRow = Record; @@ -71,12 +80,6 @@ export type NullableScope = { }; }; -export type Subquery> = { - [SubqueryMarker]: RowType; - buildAst(): SelectAst; - getRowFields(): Record; -}; - export type QueryContext = { readonly codecTypes: CodecTypesBase; readonly capabilities: Record>; diff --git a/packages/2-sql/4-lanes/sql-builder/test/cipherstash-trait-tightening.test-d.ts b/packages/2-sql/4-lanes/sql-builder/test/cipherstash-trait-tightening.test-d.ts new file mode 100644 index 0000000000..2796482ee2 --- /dev/null +++ b/packages/2-sql/4-lanes/sql-builder/test/cipherstash-trait-tightening.test-d.ts @@ -0,0 +1,96 @@ +/** + * Type-level regression test pinning the cipherstash trait-tightening + * promise made by AC3 of the `unify-query-operations` project. + * + * Before the SQL family registry collapsed `BuiltinFunctions` into the + * registry-derived `Functions`, `fns.eq` was parametric over any codec + * id — `fns.eq(cipherstashCol, cipherstashCol)` typechecked even though the + * cipherstash codec deliberately opts out of the framework `equality` + * trait (its `=` semantics aren't byte-stable across encrypts, so the + * built-in lowering returns wrong results — see + * `packages/3-extensions/cipherstash/test/equality-trait-removal.test.ts` + * for the ORM-side narrative). After the collapse, `fns.eq`'s argument + * constraint is sourced from the family's `EqualityCodecId`, which + * resolves to only the CT entries declaring the framework-canonical + * `equality` trait. Cipherstash isn't in that union, so the sql-builder + * surface refuses the call at type-check time — symmetric with the ORM + * accessor's pre-existing rejection of `cipherstashCol.eq(...)`. + */ + +import type { CodecExpression } from '@prisma-next/sql-relational-core/expression'; +import { test } from 'vitest'; +import type { Functions } from '../src/expression'; +import type { QueryContext } from '../src/scope'; + +// Synthetic codec-type map: an `equality`-trait codec (int4) plus a +// cipherstash-shaped codec that intentionally lacks the framework-canonical +// `equality` trait. Mirrors the live cipherstash descriptor's trait +// declaration (`['cipherstash:equality']` — namespaced, so it doesn't +// satisfy the bare `equality` constraint family-sql's `eq` requires). +type TestCodecTypes = { + readonly 'pg/int4@1': { + readonly input: number; + readonly output: number; + readonly traits: readonly ['equality', 'order', 'numeric']; + }; + readonly 'cipherstash/string@1': { + readonly input: string; + readonly output: string; + readonly traits: readonly ['cipherstash:equality']; + }; +}; + +// Minimal `QueryOperationTypes` carrying only the family's `eq` entry — +// the surface this test pins. Equivalent to the contract slot the family +// descriptor contributes via `SqlFamilyQueryOperationTypes`. +type TestQueryOperationTypes = { + readonly eq: { + readonly self: { readonly traits: readonly ['equality'] }; + readonly impl: < + CodecId extends { + [K in keyof TestCodecTypes & string]: TestCodecTypes[K] extends { + readonly traits: infer T; + } + ? ['equality'] extends [T extends readonly string[] ? T[number] : never] + ? K + : never + : never; + }[keyof TestCodecTypes & string], + >( + a: CodecExpression | null, + b: CodecExpression | null, + ) => { readonly returnType: { readonly codecId: 'pg/bool@1'; readonly nullable: false } }; + }; +}; + +type TestQC = QueryContext & { + readonly codecTypes: TestCodecTypes; + readonly queryOperationTypes: TestQueryOperationTypes; +}; + +declare const fns: Functions; +declare const intCol: CodecExpression<'pg/int4@1', false, TestCodecTypes>; +declare const cipherstashCol: CodecExpression<'cipherstash/string@1', false, TestCodecTypes>; + +test('fns.eq(intCol, intCol) typechecks — pg/int4@1 carries the framework `equality` trait', () => { + // No suppression; the call must resolve cleanly. Codec `pg/int4@1` + // declares `equality` in its traits, so it appears in + // `EqualityCodecId` and the generic binds successfully. + fns.eq(intCol, intCol); +}); + +test('fns.eq(cipherstashCol, cipherstashCol) fails type-check — cipherstash does not declare framework `equality`', () => { + // The cipherstash codec advertises only the namespaced + // `cipherstash:equality` trait, deliberately not the framework-canonical + // `equality` — see `packages/3-extensions/cipherstash/src/extension-metadata/codec-metadata.ts`. + // `EqualityCodecId` therefore omits `cipherstash/string@1`, + // and `fns.eq`'s `CodecId` generic cannot bind. The `@ts-expect-error` + // pins the rejection at the type level; if the cipherstash codec ever + // re-acquired the framework `equality` trait without re-routing its + // dispatch (the historical wrong-SQL footgun this whole tightening + // closes — see the ORM-side regression test + // `packages/3-extensions/cipherstash/test/equality-trait-removal.test.ts`), + // this directive would go unused and surface the regression loudly. + // @ts-expect-error cipherstash codec lacks the framework `equality` trait + fns.eq(cipherstashCol, cipherstashCol); +}); diff --git a/packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.d.ts b/packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.d.ts index 37ef4dbe5b..dd6340b419 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.d.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/fixtures/generated/contract.d.ts @@ -7,6 +7,7 @@ import type { Vector, } from '@prisma-next/extension-pgvector/codec-types'; import type { QueryOperationTypes as PgVectorQueryOperationTypes } from '@prisma-next/extension-pgvector/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -44,7 +45,8 @@ export type ProfileHash = export type CodecTypes = PgTypes & PgVectorTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps & +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps & PgVectorQueryOperationTypes; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] diff --git a/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts b/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts index 0058601945..2f8cbb408d 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/runtime/annotate.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from 'vitest'; import { sql } from '../../src/runtime/sql'; import { contract as contractJson } from '../fixtures/contract'; import type { Contract } from '../fixtures/generated/contract'; +import { testOperations } from './test-helpers'; // Builder annotate tests exercise plan-meta wiring; they don't need // real contract validation, so we cast the fixture's typed contract JSON. @@ -12,7 +13,7 @@ const sqlContract = contractJson as unknown as Contract; const stubBase = { operations: {}, codecs: {}, - queryOperations: { entries: () => ({}) }, + queryOperations: { entries: () => testOperations }, types: {}, applyMutationDefaults: () => [], }; diff --git a/packages/2-sql/4-lanes/sql-builder/test/runtime/builders.test.ts b/packages/2-sql/4-lanes/sql-builder/test/runtime/builders.test.ts index 63dc5941a4..2eec76decd 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/runtime/builders.test.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/runtime/builders.test.ts @@ -15,6 +15,7 @@ import { describe, expect, it, vi } from 'vitest'; import { sql } from '../../src/runtime/sql'; import { contract as contractJson } from '../fixtures/contract'; import type { Contract } from '../fixtures/generated/contract'; +import { testOperations } from './test-helpers'; // --------------------------------------------------------------------------- // Fixture: real contract with users + posts @@ -25,7 +26,7 @@ const sqlContract = validateSqlContractFully(contractJson); const stubBase = { operations: {}, codecs: {}, - queryOperations: { entries: () => ({}) }, + queryOperations: { entries: () => testOperations }, types: {}, applyMutationDefaults: () => [], }; diff --git a/packages/2-sql/4-lanes/sql-builder/test/runtime/functions.test.ts b/packages/2-sql/4-lanes/sql-builder/test/runtime/functions.test.ts index aece0a48aa..409e508ed3 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/runtime/functions.test.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/runtime/functions.test.ts @@ -19,18 +19,25 @@ import { ExpressionImpl } from '../../src/runtime/expression-impl'; import { createFieldProxy } from '../../src/runtime/field-proxy'; import { createAggregateFunctions, createFunctions } from '../../src/runtime/functions'; import type { ScopeField } from '../../src/scope'; -import { joinedScope, makeSubquery, usersScope } from './test-helpers'; +import { + joinedScope, + makeSubquery, + type TestAggregateFunctions, + type TestFunctions, + testOperations, + usersScope, +} from './test-helpers'; const f = () => createFieldProxy(usersScope); const jf = () => createFieldProxy(joinedScope); -const stubInferer = { inferCodec: () => 'pg/text@1' }; +const stubInferer = { inferCodec: () => 'pg/text@1' as const }; describe('createFunctions', () => { - let fns: ReturnType; + let fns: TestFunctions; beforeEach(() => { - fns = createFunctions({}, stubInferer); + fns = createFunctions(testOperations, stubInferer) as unknown as TestFunctions; }); describe('comparison operators', () => { @@ -46,8 +53,8 @@ describe('createFunctions', () => { expect((ast.right as ParamRef).value).toBe(1); }); - it('ne produces BinaryExpr with op neq', () => { - const result = fns.ne(f().id, 1); + it('neq produces BinaryExpr with op neq', () => { + const result = fns.neq(f().id, 1); expect((result.buildAst() as BinaryExpr).op).toBe('neq'); }); @@ -92,8 +99,8 @@ describe('createFunctions', () => { expect(ast.expr).toBeInstanceOf(IdentifierRef); }); - it('ne with null produces NullCheckExpr (IS NOT NULL)', () => { - const result = fns.ne(f().name, null); + it('neq with null produces NullCheckExpr (IS NOT NULL)', () => { + const result = fns.neq(f().name, null); const ast = result.buildAst() as NullCheckExpr; expect(ast).toBeInstanceOf(NullCheckExpr); @@ -240,10 +247,13 @@ describe('createFunctions', () => { }); describe('createAggregateFunctions', () => { - let fns: ReturnType; + let fns: TestAggregateFunctions; beforeEach(() => { - fns = createAggregateFunctions({}, stubInferer); + fns = createAggregateFunctions( + testOperations, + stubInferer, + ) as unknown as TestAggregateFunctions; }); it('count() produces AggregateExpr with fn count and no expr', () => { @@ -365,7 +375,7 @@ describe('extension functions', () => { describe('parameter embedding', () => { it('inline literal values are embedded as ParamRef nodes', () => { - const fns = createFunctions({}, stubInferer); + const fns = createFunctions(testOperations, stubInferer) as unknown as TestFunctions; const fields = f(); const r1 = fns.eq(fields.id, 42); @@ -378,7 +388,7 @@ describe('parameter embedding', () => { }); it('expression-to-expression comparisons do not create ParamRefs', () => { - const fns = createFunctions({}, stubInferer); + const fns = createFunctions(testOperations, stubInferer) as unknown as TestFunctions; const fields = jf(); const result = fns.eq(fields.users.id, fields.posts.user_id); diff --git a/packages/2-sql/4-lanes/sql-builder/test/runtime/test-helpers.ts b/packages/2-sql/4-lanes/sql-builder/test/runtime/test-helpers.ts index 0699aa3556..8d8a4fa27a 100644 --- a/packages/2-sql/4-lanes/sql-builder/test/runtime/test-helpers.ts +++ b/packages/2-sql/4-lanes/sql-builder/test/runtime/test-helpers.ts @@ -1,5 +1,23 @@ -import { ColumnRef, SelectAst, TableSource } from '@prisma-next/sql-relational-core/ast'; -import type { ScopeField } from '../../src/scope'; +import type { SqlOperationEntry } from '@prisma-next/sql-operations'; +import { + AndExpr, + type AnyExpression as AstExpression, + BinaryExpr, + type BinaryOp, + type CodecRef, + ColumnRef, + ExistsExpr, + ListExpression, + LiteralExpr, + NullCheckExpr, + OrExpr, + SelectAst, + SubqueryExpr, + TableSource, +} from '@prisma-next/sql-relational-core/ast'; +import { codecOf, toExpr } from '@prisma-next/sql-relational-core/expression'; +import { ExpressionImpl } from '../../src/runtime/expression-impl'; +import type { ScopeField, Subquery } from '../../src/scope'; const int4: ScopeField = { codecId: 'pg/int4@1', nullable: false, codec: { codecId: 'pg/int4@1' } }; const text: ScopeField = { codecId: 'pg/text@1', nullable: false, codec: { codecId: 'pg/text@1' } }; @@ -26,3 +44,146 @@ export function makeSubquery(): { buildAst(): SelectAst } { ); return { buildAst: () => ast }; } + +/** + * Local test fixture: a minimal SQL operations map for exercising + * `createFunctions` / `createAggregateFunctions`. Replicates the impl + * shapes the SQL family registers at runtime — sufficient for unit-testing + * the registry-lookup Proxy and the impls' AST-emission contracts (codec + * propagation, null short-circuits, list/subquery dispatch). The sql-builder + * package does not depend on `@prisma-next/family-sql`, so the impls live + * here rather than being imported from the family package. + */ +const BOOL_FIELD = { codecId: 'pg/bool@1' as const, nullable: false as const }; + +function isExpressionLike(value: unknown): value is { buildAst(): AstExpression } { + return ( + typeof value === 'object' && + value !== null && + 'buildAst' in value && + typeof (value as { buildAst: unknown }).buildAst === 'function' + ); +} + +function boolExpr(ast: AstExpression): ExpressionImpl { + return new ExpressionImpl(ast, BOOL_FIELD); +} + +function resolveOperand(operand: unknown, otherCodec: CodecRef | undefined): AstExpression { + if (isExpressionLike(operand)) return operand.buildAst(); + return toExpr(operand, otherCodec); +} + +function binaryWithSharedCodec(a: unknown, b: unknown, op: BinaryOp): AstExpression { + const aCodec = codecOf(a); + const bCodec = codecOf(b); + const left = resolveOperand(a, bCodec); + const right = resolveOperand(b, aCodec); + return new BinaryExpr(op, left, right); +} + +function toLiteralExpr(value: unknown): AstExpression { + if (isExpressionLike(value)) return value.buildAst(); + return new LiteralExpr(value); +} + +// Local helper that bridges the test-side impls (loosely typed for fixture +// brevity) to the registry's strictly-typed `SqlOperationEntry`. Production +// families construct entries through `satisfies QueryOperationTypes` +// which derives the precise signature; here the fixture exists only to +// exercise the runtime Proxy + AST emission, so the bridge is the single +// load-bearing cast — narrower than casting each impl individually. +type LooseImpl = (...args: never[]) => unknown; +function entry(impl: (...args: never[]) => unknown): SqlOperationEntry { + return { impl: impl as SqlOperationEntry['impl'] }; +} + +/** + * Concrete typed view over `testOperations` for use in the unit tests — + * loose enough to accept `Expression` operands and raw values, + * tight enough that callers see `result.buildAst()` (an + * `ExpressionImpl`). This mirrors the surface tests had + * pre-slice-3 when `BuiltinFunctions` was the source-of-truth — but + * now the impls live in `testOperations` and the registry-lookup Proxy + * dispatches to them. + */ +type BoolExpr = ExpressionImpl; +export type TestFunctions = { + eq: (a: unknown, b: unknown) => BoolExpr; + neq: (a: unknown, b: unknown) => BoolExpr; + gt: (a: unknown, b: unknown) => BoolExpr; + gte: (a: unknown, b: unknown) => BoolExpr; + lt: (a: unknown, b: unknown) => BoolExpr; + lte: (a: unknown, b: unknown) => BoolExpr; + and: (...exprs: unknown[]) => BoolExpr; + or: (...exprs: unknown[]) => BoolExpr; + exists: (subquery: Subquery>) => BoolExpr; + notExists: (subquery: Subquery>) => BoolExpr; + in: ( + expr: { buildAst(): AstExpression }, + valuesOrSubquery: Subquery> | unknown[], + ) => BoolExpr; + notIn: ( + expr: { buildAst(): AstExpression }, + valuesOrSubquery: Subquery> | unknown[], + ) => BoolExpr; +}; + +/** `TestFunctions` plus the always-present aggregate combinators. */ +export type TestAggregateFunctions = TestFunctions & { + count: (expr?: unknown) => ExpressionImpl<{ codecId: 'pg/int8@1'; nullable: false }>; + sum: (expr: unknown) => ExpressionImpl<{ codecId: string; nullable: true }>; + avg: (expr: unknown) => ExpressionImpl<{ codecId: string; nullable: true }>; + min: (expr: unknown) => ExpressionImpl<{ codecId: string; nullable: true }>; + max: (expr: unknown) => ExpressionImpl<{ codecId: string; nullable: true }>; +}; + +export const testOperations: Readonly> = { + eq: entry(((a: unknown, b: unknown) => { + if (b === null) return boolExpr(NullCheckExpr.isNull(toExpr(a))); + if (a === null) return boolExpr(NullCheckExpr.isNull(toExpr(b))); + return boolExpr(binaryWithSharedCodec(a, b, 'eq')); + }) as LooseImpl), + neq: entry(((a: unknown, b: unknown) => { + if (b === null) return boolExpr(NullCheckExpr.isNotNull(toExpr(a))); + if (a === null) return boolExpr(NullCheckExpr.isNotNull(toExpr(b))); + return boolExpr(binaryWithSharedCodec(a, b, 'neq')); + }) as LooseImpl), + gt: entry(((a: unknown, b: unknown) => boolExpr(binaryWithSharedCodec(a, b, 'gt'))) as LooseImpl), + gte: entry(((a: unknown, b: unknown) => + boolExpr(binaryWithSharedCodec(a, b, 'gte'))) as LooseImpl), + lt: entry(((a: unknown, b: unknown) => boolExpr(binaryWithSharedCodec(a, b, 'lt'))) as LooseImpl), + lte: entry(((a: unknown, b: unknown) => + boolExpr(binaryWithSharedCodec(a, b, 'lte'))) as LooseImpl), + and: entry(((...exprs: unknown[]) => + boolExpr(AndExpr.of(exprs.map(toLiteralExpr)))) as LooseImpl), + or: entry(((...exprs: unknown[]) => boolExpr(OrExpr.of(exprs.map(toLiteralExpr)))) as LooseImpl), + exists: entry(((subquery: Subquery>) => + boolExpr(ExistsExpr.exists(subquery.buildAst()))) as LooseImpl), + notExists: entry(((subquery: Subquery>) => + boolExpr(ExistsExpr.notExists(subquery.buildAst()))) as LooseImpl), + in: entry((( + expr: { buildAst(): AstExpression }, + valuesOrSubquery: Subquery> | unknown[], + ) => { + const left = expr.buildAst(); + const leftCodec = codecOf(expr); + if (Array.isArray(valuesOrSubquery)) { + const refs = valuesOrSubquery.map((v) => resolveOperand(v, leftCodec)); + return boolExpr(BinaryExpr.in(left, ListExpression.of(refs))); + } + return boolExpr(BinaryExpr.in(left, SubqueryExpr.of(valuesOrSubquery.buildAst()))); + }) as LooseImpl), + notIn: entry((( + expr: { buildAst(): AstExpression }, + valuesOrSubquery: Subquery> | unknown[], + ) => { + const left = expr.buildAst(); + const leftCodec = codecOf(expr); + if (Array.isArray(valuesOrSubquery)) { + const refs = valuesOrSubquery.map((v) => resolveOperand(v, leftCodec)); + return boolExpr(BinaryExpr.notIn(left, ListExpression.of(refs))); + } + return boolExpr(BinaryExpr.notIn(left, SubqueryExpr.of(valuesOrSubquery.buildAst()))); + }) as LooseImpl), +}; diff --git a/packages/2-sql/5-runtime/src/sql-context.ts b/packages/2-sql/5-runtime/src/sql-context.ts index 186de162dc..162ed43893 100644 --- a/packages/2-sql/5-runtime/src/sql-context.ts +++ b/packages/2-sql/5-runtime/src/sql-context.ts @@ -22,6 +22,7 @@ import { type RuntimeDriverInstance, type RuntimeExtensionDescriptor, type RuntimeExtensionInstance, + type RuntimeFamilyDescriptor, type RuntimeTargetDescriptor, type RuntimeTargetInstance, } from '@prisma-next/framework-components/execution'; @@ -140,7 +141,31 @@ export interface SqlRuntimeExtensionDescriptor; } +/** + * Runtime family descriptor for the SQL family. Structural shape + * — `RuntimeFamilyDescriptor<'sql'>` (kind / id / familyId / version / + * create) plus the `SqlStaticContributions` slots the contributors loop + * walks (`codecs`, `queryOperations`, `mutationDefaultGenerators`). + * + * Defined structurally here so `sql-runtime` does not need a workspace + * dependency on `@prisma-next/family-sql` (which would close a build + * cycle: family-sql already depends on sql-runtime for + * `RuntimeFamilyDescriptor`, `SqlStaticContributions`, etc.). Callers + * pass the concrete `sqlRuntimeFamilyDescriptor` from + * `@prisma-next/family-sql/runtime` explicitly via + * {@link createSqlExecutionStack}'s `family` parameter. + */ +export type SqlRuntimeFamilyDescriptor = RuntimeFamilyDescriptor<'sql'> & SqlStaticContributions; + export interface SqlExecutionStack { + /** + * SQL-family contributor. Required: callers compose the descriptor + * explicitly so `sql-runtime` stays free of a workspace dependency + * on `@prisma-next/family-sql`. Production callers pass + * `sqlRuntimeFamilyDescriptor` from `@prisma-next/family-sql/runtime`; + * tests typically pass a minimal stub. + */ + readonly family: SqlRuntimeFamilyDescriptor; readonly target: SqlRuntimeTargetDescriptor; readonly adapter: SqlRuntimeAdapterDescriptor; readonly extensionPacks: readonly SqlRuntimeExtensionDescriptor[]; @@ -156,6 +181,13 @@ export type SqlExecutionStackWithDriver = Omi >, 'target' | 'adapter' | 'driver' | 'extensionPacks' > & { + /** + * SQL-family contributor. Required on both the base + * {@link SqlExecutionStack} and this with-driver view; callers compose + * the descriptor explicitly so `sql-runtime` stays free of a workspace + * dependency on `@prisma-next/family-sql`. + */ + readonly family: SqlRuntimeFamilyDescriptor; readonly target: SqlRuntimeTargetDescriptor; readonly adapter: SqlRuntimeAdapterDescriptor>; readonly driver: @@ -189,13 +221,23 @@ export function createSqlExecutionStack(options: { | RuntimeDriverDescriptor<'sql', TTargetId, unknown, SqlRuntimeDriverInstance> | undefined; readonly extensionPacks?: readonly SqlRuntimeExtensionDescriptor[] | undefined; + /** + * SQL-family contributor. Required so `sql-runtime` does not depend + * on `@prisma-next/family-sql` (which would close a workspace build + * cycle). Production callers pass `sqlRuntimeFamilyDescriptor` from + * `@prisma-next/family-sql/runtime`; tests typically pass a stub. + */ + readonly family: SqlRuntimeFamilyDescriptor; }): SqlExecutionStackWithDriver { - return createExecutionStack({ - target: options.target, - adapter: options.adapter, - driver: options.driver, - extensionPacks: options.extensionPacks, - }); + return { + ...createExecutionStack({ + target: options.target, + adapter: options.adapter, + driver: options.driver, + extensionPacks: options.extensionPacks, + }), + family: options.family, + }; } export type { ExecutionContext, TypeHelperRegistry }; @@ -807,6 +849,7 @@ export function createExecutionContext< }; const contributors: Array> = [ + stack.family, stack.target, stack.adapter, ...stack.extensionPacks, diff --git a/packages/2-sql/5-runtime/test/async-iterable-result.test.ts b/packages/2-sql/5-runtime/test/async-iterable-result.test.ts index 87ef8c558c..ce6f894725 100644 --- a/packages/2-sql/5-runtime/test/async-iterable-result.test.ts +++ b/packages/2-sql/5-runtime/test/async-iterable-result.test.ts @@ -9,6 +9,7 @@ import { createTestAdapterDescriptor, createTestContext, createTestContract, + createTestFamilyDescriptor, createTestTargetDescriptor, stubAst, unboundNamespaceWithTables, @@ -81,6 +82,7 @@ const fixtureContract = createTestContract({ function createTestRuntime(mockDriver: MockDriver): Runtime { const adapter = createStubAdapter(); const stack = createSqlExecutionStack({ + family: createTestFamilyDescriptor(), target: createTestTargetDescriptor(), adapter: createTestAdapterDescriptor(adapter), extensionPacks: [], diff --git a/packages/2-sql/5-runtime/test/execution-stack.test.ts b/packages/2-sql/5-runtime/test/execution-stack.test.ts index 99d4ef4033..d0738bb510 100644 --- a/packages/2-sql/5-runtime/test/execution-stack.test.ts +++ b/packages/2-sql/5-runtime/test/execution-stack.test.ts @@ -9,7 +9,11 @@ import type { SqlRuntimeTargetDescriptor, } from '../src/sql-context'; import { defineTestCodec } from './test-codec'; -import { createTestContract, descriptorsFromCodecs } from './utils'; +import { + createTestContract, + createTestFamilyDescriptor, + descriptorsFromCodecs, +} from './utils'; function createStubAdapterDescriptor(): SqlRuntimeAdapterDescriptor<'postgres'> { const registry: ReadonlyArray> = [ @@ -117,6 +121,7 @@ describe('createExecutionStack', () => { const context = createExecutionContext({ contract, stack: { + family: createTestFamilyDescriptor(), target: createStubTargetDescriptor(), adapter: createStubAdapterDescriptor(), extensionPacks: [createStubExtensionDescriptor()], @@ -135,7 +140,11 @@ describe('createSqlExecutionStack', () => { it('preserves descriptor references and defaults extensions', () => { const target = createStubTargetDescriptor(); const adapter = createStubAdapterDescriptor(); - const stack = createSqlExecutionStack({ target, adapter }); + const stack = createSqlExecutionStack({ + family: createTestFamilyDescriptor(), + target, + adapter, + }); expect(stack.target).toBe(target); expect(stack.adapter).toBe(adapter); @@ -146,7 +155,12 @@ describe('createSqlExecutionStack', () => { const target = createStubTargetDescriptor(); const adapter = createStubAdapterDescriptor(); const extension = createStubExtensionDescriptor(); - const stack = createSqlExecutionStack({ target, adapter, extensionPacks: [extension] }); + const stack = createSqlExecutionStack({ + family: createTestFamilyDescriptor(), + target, + adapter, + extensionPacks: [extension], + }); expect(stack.extensionPacks).toEqual([extension]); }); diff --git a/packages/2-sql/5-runtime/test/intercept-decoding.test.ts b/packages/2-sql/5-runtime/test/intercept-decoding.test.ts index 96b7061923..eb1c443198 100644 --- a/packages/2-sql/5-runtime/test/intercept-decoding.test.ts +++ b/packages/2-sql/5-runtime/test/intercept-decoding.test.ts @@ -26,7 +26,7 @@ import type { import { createExecutionContext, createSqlExecutionStack } from '../src/sql-context'; import { createRuntime } from '../src/sql-runtime'; import { defineTestCodec } from './test-codec'; -import { descriptorsFromCodecs } from './utils'; +import { createTestFamilyDescriptor, descriptorsFromCodecs } from './utils'; /** * Documents the contract: when a `SqlMiddleware.intercept` hook short-circuits execution and returns raw rows, those rows go through the SQL runtime's normal codec decode pass — exactly as if they had come from the driver. @@ -151,6 +151,7 @@ function createTestSetup(middleware: readonly SqlMiddleware[]) { const adapterDescriptor = createTestAdapterDescriptor(adapter); const stack = createSqlExecutionStack({ + family: createTestFamilyDescriptor(), target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [], @@ -166,7 +167,12 @@ function createTestSetup(middleware: readonly SqlMiddleware[]) { const context = createExecutionContext({ contract: testContract, - stack: { target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [] }, + stack: { + family: createTestFamilyDescriptor(), + target: targetDescriptor, + adapter: adapterDescriptor, + extensionPacks: [], + }, }); const runtime = createRuntime({ diff --git a/packages/2-sql/5-runtime/test/marker-verification.test.ts b/packages/2-sql/5-runtime/test/marker-verification.test.ts index e338d12f6d..4119f3d373 100644 --- a/packages/2-sql/5-runtime/test/marker-verification.test.ts +++ b/packages/2-sql/5-runtime/test/marker-verification.test.ts @@ -26,7 +26,7 @@ import type { import { createExecutionContext, createSqlExecutionStack } from '../src/sql-context'; import { createRuntime } from '../src/sql-runtime'; import { defineTestCodec } from './test-codec'; -import { descriptorsFromCodecs } from './utils'; +import { createTestFamilyDescriptor, descriptorsFromCodecs } from './utils'; /** * Pins the per-result-kind branches of `verifyMarker` in `sql-runtime.ts`: absent marker @@ -184,6 +184,7 @@ function buildRuntime({ const target = createTargetDescriptor(); const adapterDesc = createAdapterDescriptor(adapter); const stack = createSqlExecutionStack({ + family: createTestFamilyDescriptor(), target, adapter: adapterDesc, extensionPacks: [], @@ -191,7 +192,12 @@ function buildRuntime({ const stackInstance = instantiateExecutionStack(stack) as SqlTestStackInstance; const context = createExecutionContext({ contract: testContract, - stack: { target, adapter: adapterDesc, extensionPacks: [] }, + stack: { + family: createTestFamilyDescriptor(), + target, + adapter: adapterDesc, + extensionPacks: [], + }, }); return createRuntime({ stackInstance, diff --git a/packages/2-sql/5-runtime/test/marker-vs-intercept-ordering.test.ts b/packages/2-sql/5-runtime/test/marker-vs-intercept-ordering.test.ts index 8dc5bff05d..f477b3a040 100644 --- a/packages/2-sql/5-runtime/test/marker-vs-intercept-ordering.test.ts +++ b/packages/2-sql/5-runtime/test/marker-vs-intercept-ordering.test.ts @@ -22,7 +22,7 @@ import type { import { createExecutionContext, createSqlExecutionStack } from '../src/sql-context'; import { createRuntime } from '../src/sql-runtime'; import { defineTestCodec } from './test-codec'; -import { descriptorsFromCodecs } from './utils'; +import { createTestFamilyDescriptor, descriptorsFromCodecs } from './utils'; /** * Pins the ordering invariant: marker verification runs upstream of `runWithMiddleware`, so the @@ -152,6 +152,7 @@ function createTestSetup(middleware: readonly SqlMiddleware[], log?: RuntimeLog) const adapterDescriptor = createTestAdapterDescriptor(adapter); const stack = createSqlExecutionStack({ + family: createTestFamilyDescriptor(), target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [], @@ -167,7 +168,12 @@ function createTestSetup(middleware: readonly SqlMiddleware[], log?: RuntimeLog) const context = createExecutionContext({ contract: testContract, - stack: { target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [] }, + stack: { + family: createTestFamilyDescriptor(), + target: targetDescriptor, + adapter: adapterDescriptor, + extensionPacks: [], + }, }); const runtime = createRuntime({ diff --git a/packages/2-sql/5-runtime/test/mutation-default-generators.test.ts b/packages/2-sql/5-runtime/test/mutation-default-generators.test.ts index 662864b58e..8a2b2d3d60 100644 --- a/packages/2-sql/5-runtime/test/mutation-default-generators.test.ts +++ b/packages/2-sql/5-runtime/test/mutation-default-generators.test.ts @@ -10,6 +10,7 @@ import { import { createStubAdapter, createTestAdapterDescriptor, + createTestFamilyDescriptor, createTestTargetDescriptor, } from './utils'; @@ -46,6 +47,7 @@ function createStack( extensionPacks: ReadonlyArray>, ): SqlExecutionStack<'postgres'> { return { + family: createTestFamilyDescriptor(), target: createTestTargetDescriptor(), adapter: createTestAdapterDescriptor(createStubAdapter()), extensionPacks, @@ -239,6 +241,7 @@ describe('composed runtime mutation default generators', () => { }, }, stack: { + family: createTestFamilyDescriptor(), target: createTestTargetDescriptor(), adapter: adapterWithoutMutationDefaultGenerators, extensionPacks: [], diff --git a/packages/2-sql/5-runtime/test/plan-execution-id.test.ts b/packages/2-sql/5-runtime/test/plan-execution-id.test.ts index 7c5290bf14..4375cbd79d 100644 --- a/packages/2-sql/5-runtime/test/plan-execution-id.test.ts +++ b/packages/2-sql/5-runtime/test/plan-execution-id.test.ts @@ -23,6 +23,7 @@ import { createTestAdapterDescriptor, createTestContext, createTestContract, + createTestFamilyDescriptor, createTestTargetDescriptor, } from './utils'; @@ -61,6 +62,7 @@ function createSetup(middleware: readonly SqlMiddleware[]) { const targetDescriptor = createTestTargetDescriptor(); const adapterDescriptor = createTestAdapterDescriptor(adapter); const stack = createSqlExecutionStack({ + family: createTestFamilyDescriptor(), target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [], diff --git a/packages/2-sql/5-runtime/test/prepared.test.ts b/packages/2-sql/5-runtime/test/prepared.test.ts index c83d10ed16..17875cb6bb 100644 --- a/packages/2-sql/5-runtime/test/prepared.test.ts +++ b/packages/2-sql/5-runtime/test/prepared.test.ts @@ -24,6 +24,7 @@ import { createTestAdapterDescriptor, createTestContext, createTestContract, + createTestFamilyDescriptor, createTestTargetDescriptor, } from './utils'; @@ -78,6 +79,7 @@ function createSetup(options?: { const targetDescriptor = createTestTargetDescriptor(); const adapterDescriptor = createTestAdapterDescriptor(adapter); const stack = createSqlExecutionStack({ + family: createTestFamilyDescriptor(), target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [], diff --git a/packages/2-sql/5-runtime/test/runtime-ctx-passthrough.test.ts b/packages/2-sql/5-runtime/test/runtime-ctx-passthrough.test.ts index 645cef7118..06413e5944 100644 --- a/packages/2-sql/5-runtime/test/runtime-ctx-passthrough.test.ts +++ b/packages/2-sql/5-runtime/test/runtime-ctx-passthrough.test.ts @@ -21,7 +21,7 @@ import type { import { createExecutionContext, createSqlExecutionStack } from '../src/sql-context'; import { createRuntime } from '../src/sql-runtime'; import { defineTestCodec } from './test-codec'; -import { descriptorsFromCodecs } from './utils'; +import { createTestFamilyDescriptor, descriptorsFromCodecs } from './utils'; /** * Pins that the SQL runtime's middleware ctx exposes a working `now()` clock and `contentHash()` plan hasher even when no `log` was supplied to `createRuntime` (default noop log path). @@ -117,7 +117,12 @@ describe('SQL middleware context surface', () => { ) as SqlRuntimeAdapterInstance<'postgres'>; }, }; - const stack = createSqlExecutionStack({ target, adapter: adapterDesc, extensionPacks: [] }); + const stack = createSqlExecutionStack({ + family: createTestFamilyDescriptor(), + target, + adapter: adapterDesc, + extensionPacks: [], + }); type SqlTestStackInstance = ExecutionStackInstance< 'sql', 'postgres', @@ -128,7 +133,12 @@ describe('SQL middleware context surface', () => { const stackInstance = instantiateExecutionStack(stack) as SqlTestStackInstance; const context = createExecutionContext({ contract: testContract, - stack: { target, adapter: adapterDesc, extensionPacks: [] }, + stack: { + family: createTestFamilyDescriptor(), + target, + adapter: adapterDesc, + extensionPacks: [], + }, }); let observedNow: number | undefined; diff --git a/packages/2-sql/5-runtime/test/scope-plumbing.test.ts b/packages/2-sql/5-runtime/test/scope-plumbing.test.ts index 04e7f33f20..38505730b3 100644 --- a/packages/2-sql/5-runtime/test/scope-plumbing.test.ts +++ b/packages/2-sql/5-runtime/test/scope-plumbing.test.ts @@ -25,7 +25,7 @@ import type { import { createExecutionContext, createSqlExecutionStack } from '../src/sql-context'; import { createRuntime } from '../src/sql-runtime'; import { defineTestCodec } from './test-codec'; -import { descriptorsFromCodecs, stubAst } from './utils'; +import { createTestFamilyDescriptor, descriptorsFromCodecs, stubAst } from './utils'; /** * Verifies the SQL runtime populates `RuntimeMiddlewareContext.scope` @@ -167,6 +167,7 @@ function createTestSetup(middleware: readonly SqlMiddleware[]) { const adapterDescriptor = createTestAdapterDescriptor(adapter); const stack = createSqlExecutionStack({ + family: createTestFamilyDescriptor(), target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [], @@ -182,7 +183,12 @@ function createTestSetup(middleware: readonly SqlMiddleware[]) { const context = createExecutionContext({ contract: testContract, - stack: { target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [] }, + stack: { + family: createTestFamilyDescriptor(), + target: targetDescriptor, + adapter: adapterDescriptor, + extensionPacks: [], + }, }); const runtime = createRuntime({ diff --git a/packages/2-sql/5-runtime/test/sql-context.test.ts b/packages/2-sql/5-runtime/test/sql-context.test.ts index 4f8bd711da..a5b7dd38f4 100644 --- a/packages/2-sql/5-runtime/test/sql-context.test.ts +++ b/packages/2-sql/5-runtime/test/sql-context.test.ts @@ -22,6 +22,7 @@ import { defineTestCodec } from './test-codec'; import { createStubAdapter, createTestAdapterDescriptor, + createTestFamilyDescriptor, createTestTargetDescriptor, descriptorsFromCodecs, } from './utils'; @@ -88,6 +89,7 @@ function createStack(options?: { extensionPacks?: ReadonlyArray>; }): SqlExecutionStack<'postgres'> { return { + family: createTestFamilyDescriptor(), target: createTestTargetDescriptor(), adapter: createTestAdapterDescriptor(createStubAdapter()), extensionPacks: options?.extensionPacks ?? [], @@ -185,6 +187,7 @@ describe('comprehensive descriptor-based derivation', () => { }; const stack: SqlExecutionStack<'postgres'> = { + family: createTestFamilyDescriptor(), target, adapter: createTestAdapterDescriptor(createStubAdapter()), extensionPacks: [createTestExtensionDescriptor({ hasCodecs: true, hasOperations: true })], @@ -852,6 +855,7 @@ describe('capability folding', () => { it('folds adapter capabilities into context.contract.capabilities', () => { const stack: SqlExecutionStack<'postgres'> = { + family: createTestFamilyDescriptor(), target: createTestTargetDescriptor(), adapter: adapterWithCapabilities({ sql: { returning: true } }), extensionPacks: [], @@ -866,6 +870,7 @@ describe('capability folding', () => { const context = createExecutionContext({ contract: testContract, stack: { + family: createTestFamilyDescriptor(), target: createTestTargetDescriptor(), adapter: createTestAdapterDescriptor(createStubAdapter()), extensionPacks: [], @@ -878,6 +883,7 @@ describe('capability folding', () => { it('later contributor wins on key collision (adapter overrides target)', () => { const stack: SqlExecutionStack<'postgres'> = { + family: createTestFamilyDescriptor(), target: targetWithCapabilities({ sql: { returning: false } }), adapter: adapterWithCapabilities({ sql: { returning: true } }), extensionPacks: [], @@ -898,6 +904,7 @@ describe('capability folding', () => { const context = createExecutionContext({ contract: inputContract, stack: { + family: createTestFamilyDescriptor(), target: createTestTargetDescriptor(), adapter: adapterWithCapabilities({ sql: { returning: true } }), extensionPacks: [], @@ -929,7 +936,12 @@ describe('capability folding', () => { const context = createExecutionContext({ contract: testContract, - stack: { target, adapter, extensionPacks: [extension] }, + stack: { + family: createTestFamilyDescriptor(), + target, + adapter, + extensionPacks: [extension], + }, driver, }); @@ -941,6 +953,7 @@ describe('capability folding', () => { postgres: { lateral: true }, }); const stack: SqlExecutionStack<'postgres'> = { + family: createTestFamilyDescriptor(), target: createTestTargetDescriptor(), adapter: createTestAdapterDescriptor(createStubAdapter()), extensionPacks: [extension, extension], diff --git a/packages/2-sql/5-runtime/test/sql-runtime-abort.test.ts b/packages/2-sql/5-runtime/test/sql-runtime-abort.test.ts index 387056fb25..178a694e81 100644 --- a/packages/2-sql/5-runtime/test/sql-runtime-abort.test.ts +++ b/packages/2-sql/5-runtime/test/sql-runtime-abort.test.ts @@ -30,7 +30,7 @@ import type { import { createExecutionContext, createSqlExecutionStack } from '../src/sql-context'; import { createRuntime } from '../src/sql-runtime'; import { defineTestCodec } from './test-codec'; -import { descriptorsFromCodecs, stubAst } from './utils'; +import { createTestFamilyDescriptor, descriptorsFromCodecs, stubAst } from './utils'; const testContract: Contract = { targetFamily: 'sql', @@ -154,6 +154,7 @@ function createTestSetup(extras: readonly Codec[] = [], driverOptions?: }; const stack = createSqlExecutionStack({ + family: createTestFamilyDescriptor(), target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [], @@ -169,7 +170,12 @@ function createTestSetup(extras: readonly Codec[] = [], driverOptions?: const context = createExecutionContext({ contract: testContract, - stack: { target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [] }, + stack: { + family: createTestFamilyDescriptor(), + target: targetDescriptor, + adapter: adapterDescriptor, + extensionPacks: [], + }, }); return { stackInstance, context, driver }; diff --git a/packages/2-sql/5-runtime/test/sql-runtime.test.ts b/packages/2-sql/5-runtime/test/sql-runtime.test.ts index a94d66fa4c..f4c1ef158f 100644 --- a/packages/2-sql/5-runtime/test/sql-runtime.test.ts +++ b/packages/2-sql/5-runtime/test/sql-runtime.test.ts @@ -31,7 +31,7 @@ import { createExecutionContext, createSqlExecutionStack } from '../src/sql-cont import { createRuntime, withTransaction } from '../src/sql-runtime'; import { createAsyncSecretCodec, decryptSecret } from './seeded-secret-codec'; import { defineTestCodec } from './test-codec'; -import { descriptorsFromCodecs, stubAst } from './utils'; +import { createTestFamilyDescriptor, descriptorsFromCodecs, stubAst } from './utils'; const runtimeSecretSeed = 'sql-runtime-secret'; @@ -202,6 +202,7 @@ function createTestSetup(options?: { extraCodecs?: readonly Codec[] }) { const adapterDescriptor = createTestAdapterDescriptor(adapter); const stack = createSqlExecutionStack({ + family: createTestFamilyDescriptor(), target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [], @@ -217,7 +218,12 @@ function createTestSetup(options?: { extraCodecs?: readonly Codec[] }) { const context = createExecutionContext({ contract: testContract, - stack: { target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [] }, + stack: { + family: createTestFamilyDescriptor(), + target: targetDescriptor, + adapter: adapterDescriptor, + extensionPacks: [], + }, }); return { stackInstance, context, driver }; @@ -470,6 +476,7 @@ describe('createRuntime', () => { const targetDescriptor = createTestTargetDescriptor(); const adapterDescriptor = createTestAdapterDescriptor(adapter); const stack = createSqlExecutionStack({ + family: createTestFamilyDescriptor(), target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [], @@ -483,7 +490,12 @@ describe('createRuntime', () => { >; const context = createExecutionContext({ contract: testContract, - stack: { target: targetDescriptor, adapter: adapterDescriptor, extensionPacks: [] }, + stack: { + family: createTestFamilyDescriptor(), + target: targetDescriptor, + adapter: adapterDescriptor, + extensionPacks: [], + }, }); const rewriteA: SqlMiddleware = { diff --git a/packages/2-sql/5-runtime/test/utils.ts b/packages/2-sql/5-runtime/test/utils.ts index a992cf94ab..3ec1bd2807 100644 --- a/packages/2-sql/5-runtime/test/utils.ts +++ b/packages/2-sql/5-runtime/test/utils.ts @@ -54,6 +54,7 @@ import type { SqlRuntimeAdapterInstance, SqlRuntimeDriverInstance, SqlRuntimeExtensionDescriptor, + SqlRuntimeFamilyDescriptor, SqlRuntimeTargetDescriptor, } from '../src/sql-context'; import { defineTestCodec } from './test-codec'; @@ -262,6 +263,28 @@ export function createTestTargetDescriptor(): SqlRuntimeTargetDescriptor<'postgr }; } +/** + * Minimal stub family descriptor for tests. Real callers pass + * `sqlRuntimeFamilyDescriptor` from `@prisma-next/family-sql/runtime`; + * sql-runtime's own tests cannot import family-sql (workspace cycle) so + * they pass this stub instead. The stub registers no codecs and no + * query operations — sufficient for tests that exercise stack / + * context / runtime plumbing without invoking real SQL operations. + */ +export function createTestFamilyDescriptor(): SqlRuntimeFamilyDescriptor { + return { + kind: 'family' as const, + id: 'sql', + familyId: 'sql' as const, + version: '0.0.1', + codecs: () => [], + queryOperations: () => ({}), + create() { + return { familyId: 'sql' as const }; + }, + }; +} + /** * Creates an ExecutionContext for testing. This helper DRYs up the common pattern of context creation in tests. * @@ -277,6 +300,7 @@ export function createTestContext>( return createExecutionContext({ contract, stack: { + family: createTestFamilyDescriptor(), target: createTestTargetDescriptor(), adapter: createTestAdapterDescriptor(adapter), extensionPacks: options?.extensionPacks ?? [], @@ -294,6 +318,7 @@ export function createTestStackInstance(options?: { >; }) { const stack = createSqlExecutionStack({ + family: createTestFamilyDescriptor(), target: createTestTargetDescriptor(), adapter: createTestAdapterDescriptor(createStubAdapter()), driver: options?.driver, diff --git a/packages/2-sql/9-family/package.json b/packages/2-sql/9-family/package.json index 6a8b16576b..42b56d9a62 100644 --- a/packages/2-sql/9-family/package.json +++ b/packages/2-sql/9-family/package.json @@ -58,6 +58,7 @@ "./control-adapter": "./dist/control-adapter.mjs", "./ir": "./dist/ir.mjs", "./migration": "./dist/migration.mjs", + "./operation-types": "./dist/operation-types.mjs", "./pack": "./dist/pack.mjs", "./runtime": "./dist/runtime.mjs", "./schema-verify": "./dist/schema-verify.mjs", diff --git a/packages/2-sql/9-family/src/core/control-descriptor.ts b/packages/2-sql/9-family/src/core/control-descriptor.ts index 19bf265b74..e35cb39efb 100644 --- a/packages/2-sql/9-family/src/core/control-descriptor.ts +++ b/packages/2-sql/9-family/src/core/control-descriptor.ts @@ -20,6 +20,23 @@ export class SqlFamilyDescriptor field: sqlFamilyAuthoringFieldPresets, type: sqlFamilyAuthoringTypes, } as const; + /** + * Descriptor-meta type slots — read by the contract emitter's + * alias-aggregation step (`extractQueryOperationTypeImports` in + * `framework-components/control/control-stack.ts`) to lift the family's + * `QueryOperationTypes` into the generated `Contract['queryOperationTypes']` + * alias intersection. Mirrors the pattern at + * `packages/3-extensions/pgvector/src/core/descriptor-meta.ts:97-103`. + */ + readonly types = { + queryOperationTypes: { + import: { + package: '@prisma-next/family-sql/operation-types', + named: 'QueryOperationTypes', + alias: 'SqlFamilyQueryOperationTypes', + }, + }, + } as const; create( stack: ControlStack<'sql', TTargetId>, diff --git a/packages/2-sql/9-family/src/core/query-operations.ts b/packages/2-sql/9-family/src/core/query-operations.ts new file mode 100644 index 0000000000..7917c2179f --- /dev/null +++ b/packages/2-sql/9-family/src/core/query-operations.ts @@ -0,0 +1,236 @@ +/** + * Source-of-truth runtime factory for the SQL family's query operations. + * + * Returns the 15 family-level operation descriptors registered into the + * `SqlOperationRegistry` at execution-context construction: + * + * - Equality predicates (trait `equality`): `eq`, `neq`, `in`, `notIn` + * - Order predicates (trait `order`): `gt`, `gte`, `lt`, `lte` + * - Textual predicate (trait `textual`): `like` + * - Null checks (any codec): `isNull`, `isNotNull` + * - Boolean composition (no `self`, sql-builder-only): `and`, `or`, + * `exists`, `notExists` + * + * The sql-builder `fns` proxy and the ORM column accessors both source + * these impls through the registry; this file is the canonical home for + * their lowering — no twin implementation exists elsewhere. + * + * Lock-step with the type twin in `../types/operation-types.ts` is + * enforced via `satisfies QueryOperationTypes` on the returned + * literal — a drift in lowering shape vs. type-level signature surfaces + * as a family-sql typecheck failure rather than a downstream SQL-emission + * regression. + */ + +import { + AndExpr, + type AnyExpression as AstExpression, + BinaryExpr, + type BinaryOp, + type CodecRef, + ExistsExpr, + ListExpression, + LiteralExpr, + NullCheckExpr, + OrExpr, + SubqueryExpr, +} from '@prisma-next/sql-relational-core/ast'; +import { + type CodecExpression, + type CodecTypesBase, + codecOf, + type Expression, + type ScopeField, + type Subquery, + toExpr, +} from '@prisma-next/sql-relational-core/expression'; +import type { QueryOperationTypes } from '../types/operation-types'; + +type PgBoolReturn = Expression<{ codecId: 'pg/bool@1'; nullable: false }>; + +const BOOL_FIELD = { codecId: 'pg/bool@1' as const, nullable: false as const }; + +/** + * Wrap a relational-core AST node as an `Expression` — + * the canonical `pg/bool@1` return wrapping used by every predicate + * factory below. + * + * Uses a plain object literal rather than `sql-builder`'s `ExpressionImpl` + * to keep `family-sql` free of a workspace edge to `sql-builder` (the + * cycle-breaking constraint). Downstream consumers only read + * `returnType` / `buildAst()` from the wrapper — no `instanceof` checks + * — so the structural shape is sufficient. + */ +function boolExpr(ast: AstExpression): PgBoolReturn { + return { returnType: BOOL_FIELD, buildAst: () => ast }; +} + +/** + * Runtime-level operand union — accepts an `Expression` or any raw + * value. Concrete codec typing lives on the public + * `QueryOperationTypes` surface; the runtime impl widens to + * `CodecExpression` so the body can be + * shared across every codec id without per-call generic resolution. + */ +type ExprOrVal = CodecExpression; + +function isExpressionLike(value: unknown): value is { buildAst(): AstExpression } { + return ( + typeof value === 'object' && + value !== null && + 'buildAst' in value && + typeof (value as { buildAst: unknown }).buildAst === 'function' + ); +} + +/** + * Resolve a binary-comparison operand into an AST node, threading the + * column-bound side's {@link CodecRef} to the raw-value side. Used by + * `eq` / `neq` / `gt` / `gte` / `lt` / `lte` / `like` to forward the + * column's codec onto raw-value `ParamRef`s for encode-side dispatch. + */ +function resolveOperand(operand: ExprOrVal, otherCodec: CodecRef | undefined): AstExpression { + if (isExpressionLike(operand)) return operand.buildAst(); + return toExpr(operand, otherCodec); +} + +/** + * Build a binary AST node with cross-codec resolution. Each side + * forwards its codec ref so a raw value paired with a column-bound + * expression picks up the column's codec at param materialisation. + */ +function binaryWithSharedCodec(a: ExprOrVal, b: ExprOrVal, op: BinaryOp): AstExpression { + const aCodec = codecOf(a); + const bCodec = codecOf(b); + const left = resolveOperand(a, bCodec); + const right = resolveOperand(b, aCodec); + return new BinaryExpr(op, left, right); +} + +/** + * Wrap an Expression (via `buildAst`) or fall back to a `LiteralExpr`. + * Used by `and` / `or` so callers can mix in raw `true` / `false` + * literals that the SQL planner statically simplifies (e.g. + * `TRUE AND x → x`, which it cannot do for an opaque `ParamRef`). + */ +function toLiteralExpr(value: unknown): AstExpression { + if (isExpressionLike(value)) return value.buildAst(); + return new LiteralExpr(value); +} + +/** + * Family-SQL query-operations contribution. Structure mirrors + * `pgvectorQueryOperations()` and is locked-step with D1's + * `QueryOperationTypes` type twin via `satisfies`. + */ +export function sqlFamilyOperations(): QueryOperationTypes { + return { + // Equality predicates — trait-gated. + // `eq` / `neq` preserve the implicit null-coalescing convention the + // sql-builder `fns.eq` and ORM `column.eq` accessors both expose: + // a `null` operand short-circuits to `NullCheckExpr.isNull` / + // `isNotNull` so users do not have to switch surface to express the + // common `column.eq(maybeNull)` pattern. + eq: { + self: { traits: ['equality'] }, + impl: (a, b) => { + if (b === null) return boolExpr(NullCheckExpr.isNull(toExpr(a))); + if (a === null) return boolExpr(NullCheckExpr.isNull(toExpr(b))); + return boolExpr(binaryWithSharedCodec(a as ExprOrVal, b as ExprOrVal, 'eq')); + }, + }, + neq: { + self: { traits: ['equality'] }, + impl: (a, b) => { + if (b === null) return boolExpr(NullCheckExpr.isNotNull(toExpr(a))); + if (a === null) return boolExpr(NullCheckExpr.isNotNull(toExpr(b))); + return boolExpr(binaryWithSharedCodec(a as ExprOrVal, b as ExprOrVal, 'neq')); + }, + }, + in: { + self: { traits: ['equality'] }, + // Runtime branches on the second arg's shape: an array of values or + // a subquery. The type twin carries the two overload signatures so + // callers see the precise signature; the runtime widens to the + // union and branches. + impl: (( + expr: Expression, + valuesOrSubquery: Subquery> | ReadonlyArray, + ): PgBoolReturn => { + const left = expr.buildAst(); + const leftCodec = codecOf(expr); + if (Array.isArray(valuesOrSubquery)) { + const refs = valuesOrSubquery.map((v) => resolveOperand(v, leftCodec)); + return boolExpr(BinaryExpr.in(left, ListExpression.of(refs))); + } + const subquery = valuesOrSubquery as Subquery>; + return boolExpr(BinaryExpr.in(left, SubqueryExpr.of(subquery.buildAst()))); + }) as QueryOperationTypes['in']['impl'], + }, + notIn: { + self: { traits: ['equality'] }, + impl: (( + expr: Expression, + valuesOrSubquery: Subquery> | ReadonlyArray, + ): PgBoolReturn => { + const left = expr.buildAst(); + const leftCodec = codecOf(expr); + if (Array.isArray(valuesOrSubquery)) { + const refs = valuesOrSubquery.map((v) => resolveOperand(v, leftCodec)); + return boolExpr(BinaryExpr.notIn(left, ListExpression.of(refs))); + } + const subquery = valuesOrSubquery as Subquery>; + return boolExpr(BinaryExpr.notIn(left, SubqueryExpr.of(subquery.buildAst()))); + }) as QueryOperationTypes['notIn']['impl'], + }, + + // Order predicates — trait-gated. + gt: { + self: { traits: ['order'] }, + impl: (a, b) => boolExpr(binaryWithSharedCodec(a as ExprOrVal, b as ExprOrVal, 'gt')), + }, + gte: { + self: { traits: ['order'] }, + impl: (a, b) => boolExpr(binaryWithSharedCodec(a as ExprOrVal, b as ExprOrVal, 'gte')), + }, + lt: { + self: { traits: ['order'] }, + impl: (a, b) => boolExpr(binaryWithSharedCodec(a as ExprOrVal, b as ExprOrVal, 'lt')), + }, + lte: { + self: { traits: ['order'] }, + impl: (a, b) => boolExpr(binaryWithSharedCodec(a as ExprOrVal, b as ExprOrVal, 'lte')), + }, + + // Textual predicate — trait-gated. + like: { + self: { traits: ['textual'] }, + impl: (a, b) => boolExpr(binaryWithSharedCodec(a as ExprOrVal, b as ExprOrVal, 'like')), + }, + + // Null checks — any codec. + isNull: { + self: { any: true }, + impl: (expr) => boolExpr(NullCheckExpr.isNull(expr.buildAst())), + }, + isNotNull: { + self: { any: true }, + impl: (expr) => boolExpr(NullCheckExpr.isNotNull(expr.buildAst())), + }, + + // Boolean composition — no `self` (sql-builder-only; never surfaces + // as a column method). + and: { + impl: (...exprs) => boolExpr(AndExpr.of(exprs.map(toLiteralExpr))), + }, + or: { + impl: (...exprs) => boolExpr(OrExpr.of(exprs.map(toLiteralExpr))), + }, + exists: { + impl: (subquery) => boolExpr(ExistsExpr.exists(subquery.buildAst())), + }, + notExists: { + impl: (subquery) => boolExpr(ExistsExpr.notExists(subquery.buildAst())), + }, + } satisfies QueryOperationTypes; +} diff --git a/packages/2-sql/9-family/src/core/runtime-descriptor.ts b/packages/2-sql/9-family/src/core/runtime-descriptor.ts index 5126c6f2a5..9e894cda5c 100644 --- a/packages/2-sql/9-family/src/core/runtime-descriptor.ts +++ b/packages/2-sql/9-family/src/core/runtime-descriptor.ts @@ -1,4 +1,6 @@ import type { RuntimeFamilyDescriptor } from '@prisma-next/framework-components/execution'; +import type { SqlStaticContributions } from '@prisma-next/sql-runtime'; +import { sqlFamilyOperations } from './query-operations'; import { createSqlRuntimeFamilyInstance, type SqlRuntimeFamilyInstance } from './runtime-instance'; /** @@ -8,16 +10,25 @@ import { createSqlRuntimeFamilyInstance, type SqlRuntimeFamilyInstance } from '. * framework types are still using the `Runtime*` naming (`RuntimeFamilyDescriptor`, etc.). * * This will be renamed to `sqlExecutionFamilyDescriptor` as part of `TML-1842`. + * + * The descriptor also satisfies the structural `SqlStaticContributions` + * shape: `codecs()` returns an empty list (the family owns no codecs — + * targets and adapters do), and `queryOperations()` returns the family's + * 15 query-operation descriptors. Slice 3 wires the family into + * `createExecutionContext`'s contributors loop so the registry picks up + * these entries alongside the target's and adapter's. */ -export const sqlRuntimeFamilyDescriptor: RuntimeFamilyDescriptor<'sql', SqlRuntimeFamilyInstance> = - { - kind: 'family', - id: 'sql', - familyId: 'sql', - version: '0.0.1', - create() { - return createSqlRuntimeFamilyInstance(); - }, - }; +export const sqlRuntimeFamilyDescriptor: RuntimeFamilyDescriptor<'sql', SqlRuntimeFamilyInstance> & + SqlStaticContributions = { + kind: 'family', + id: 'sql', + familyId: 'sql', + version: '0.0.1', + codecs: () => [], + queryOperations: () => sqlFamilyOperations(), + create() { + return createSqlRuntimeFamilyInstance(); + }, +}; Object.freeze(sqlRuntimeFamilyDescriptor); diff --git a/packages/2-sql/9-family/src/exports/operation-types.ts b/packages/2-sql/9-family/src/exports/operation-types.ts new file mode 100644 index 0000000000..853ff5fd0b --- /dev/null +++ b/packages/2-sql/9-family/src/exports/operation-types.ts @@ -0,0 +1,16 @@ +/** + * Operation type definitions for the SQL family. + * + * Public barrel that re-exports the type-only twin of the SQL family's + * 15 operations. Imported by the family's control descriptor's + * `types.queryOperationTypes` slot so the contract emitter aggregates + * `SqlFamilyQueryOperationTypes` into the generated + * `Contract['queryOperationTypes']`. + */ + +export type { + EqualityCodecId, + OrderCodecId, + QueryOperationTypes, + TextualCodecId, +} from '../types/operation-types'; diff --git a/packages/2-sql/9-family/src/types/operation-types.ts b/packages/2-sql/9-family/src/types/operation-types.ts new file mode 100644 index 0000000000..5a28add7fd --- /dev/null +++ b/packages/2-sql/9-family/src/types/operation-types.ts @@ -0,0 +1,228 @@ +/** + * Operation type definitions for the SQL family. + * + * Type-only twin of the runtime factory in `core/query-operations.ts`. + * The twin carries the 15 family-level SQL operations the registry + * registers at execution context construction: + * + * - Equality predicates (trait `equality`): `eq`, `neq`, `in`, `notIn` + * - Order predicates (trait `order`): `gt`, `gte`, `lt`, `lte` + * - Textual predicate (trait `textual`): `like` + * - Null checks (any codec): `isNull`, `isNotNull` + * - Boolean composition (no `self`, sql-builder-only): `and`, `or`, + * `exists`, `notExists` + * + * The binary trait-gated operators follow ADR 203's "How matching works" + * trait-constrained codec-id generic pattern: the helper types + * (`EqualityCodecId` / `OrderCodecId` / `TextualCodecId`) + * resolve to the union of CT codec ids whose `traits` set includes the + * relevant trait. A user-visible `fns.eq(a, b)` call therefore type-checks + * only when `a` and `b` share a codec id from the equality-trait subset. + * + * The runtime factory uses `satisfies QueryOperationTypes` so the + * runtime stays in lock-step with this type-level shape. + * + * This file contains **types only**: it imports type-only symbols and + * carries no runtime code. + */ + +import type { + QueryOperationTypeEntry, + SqlQueryOperationTypes, +} from '@prisma-next/sql-contract/types'; +import type { + CodecExpression, + CodecTypesBase, + Expression, + ScopeField, + Subquery, +} from '@prisma-next/sql-relational-core/expression'; + +/** + * Return-codec type for every predicate operator in the family. The + * runtime impls all build expressions whose `returnType.codecId` is + * `pg/bool@1`; the matching type-level shape pins the boolean codec so + * predicate detection downstream (the `where(...)` body) can resolve. + */ +type PgBoolReturn = Expression<{ codecId: 'pg/bool@1'; nullable: false }>; + +/** + * Filter helper: the union of CT codec ids whose `traits` set contains + * every required trait. Mirrors the unexported `CodecIdsWithTrait` + * helper in `relational-core/src/expression.ts` — the same mechanism + * ADR 203 documents for `fns.ilike` argument resolution. + * + * Gate shape: `[RequiredTraits[number]] extends [T]`. The bracketed + * form prevents conditional distribution, so the gate reads as "every + * required trait is present in `T`" — correct whether `T` arrives as a + * tuple (`readonly ['equality', 'order', 'numeric']`) or as a union + * (`'equality' | 'order' | 'numeric'`). `ExtractCodecTypes` + * (`relational-core/src/expression.ts`) flattens descriptor `traits` + * tuples to unions via `DescriptorCodecTraits = TTraits[number] & + * CodecTrait`, so in every real-contract instantiation `T` is a union; + * the earlier `T extends readonly string[] ? T[number] : never` gate + * tripped on that union case and cascaded to `never`. The three + * exported wrappers below pass a one-element tuple + * (`readonly ['']`) so the single-trait callsites read the same + * as before from the consumer side. + */ +type CodecIdsWithTrait< + CT extends CodecTypesBase, + RequiredTraits extends readonly string[], +> = { + [K in keyof CT & string]: CT[K] extends { readonly traits: infer T } + ? [RequiredTraits[number]] extends [T] + ? K + : never + : never; +}[keyof CT & string]; + +/** Codec ids in `CT` declaring the `equality` trait. */ +export type EqualityCodecId = CodecIdsWithTrait< + CT, + readonly ['equality'] +>; + +/** Codec ids in `CT` declaring the `order` trait. */ +export type OrderCodecId = CodecIdsWithTrait; + +/** Codec ids in `CT` declaring the `textual` trait. */ +export type TextualCodecId = CodecIdsWithTrait< + CT, + readonly ['textual'] +>; + +/** + * Flat operation signatures for the SQL family. Composed into the + * generated `Contract['queryOperationTypes']` via the family control + * descriptor's `types.queryOperationTypes` slot. + * + * Each entry's `self` shape mirrors the runtime registration 1:1: + * + * - Trait-gated entries (the 9 predicates) declare + * `self: { traits: [...] }`. Trait dispatch surfaces the method on + * every column whose codec id resolves to a CT entry whose `traits` + * set includes the gating trait. + * - Null-check entries declare `self: { any: true }`, surfacing the + * method on every codec regardless of trait. + * - Boolean composition entries (`and`, `or`, `exists`, `notExists`) + * omit `self` — they are sql-builder-only and never surface as a + * column method. + */ +export type QueryOperationTypes = SqlQueryOperationTypes< + CT, + { + // Equality predicates — trait-gated + readonly eq: { + readonly self: { readonly traits: readonly ['equality'] }; + readonly impl: >( + a: CodecExpression | null, + b: CodecExpression | null, + ) => PgBoolReturn; + }; + readonly neq: { + readonly self: { readonly traits: readonly ['equality'] }; + readonly impl: >( + a: CodecExpression | null, + b: CodecExpression | null, + ) => PgBoolReturn; + }; + readonly in: { + readonly self: { readonly traits: readonly ['equality'] }; + readonly impl: { + >( + expr: Expression<{ codecId: CodecId; nullable: boolean }>, + subquery: Subquery>, + ): PgBoolReturn; + >( + expr: Expression<{ codecId: CodecId; nullable: boolean }>, + values: ReadonlyArray>, + ): PgBoolReturn; + }; + }; + readonly notIn: { + readonly self: { readonly traits: readonly ['equality'] }; + readonly impl: { + >( + expr: Expression<{ codecId: CodecId; nullable: boolean }>, + subquery: Subquery>, + ): PgBoolReturn; + >( + expr: Expression<{ codecId: CodecId; nullable: boolean }>, + values: ReadonlyArray>, + ): PgBoolReturn; + }; + }; + + // Order predicates — trait-gated + readonly gt: { + readonly self: { readonly traits: readonly ['order'] }; + readonly impl: >( + a: CodecExpression, + b: CodecExpression, + ) => PgBoolReturn; + }; + readonly gte: { + readonly self: { readonly traits: readonly ['order'] }; + readonly impl: >( + a: CodecExpression, + b: CodecExpression, + ) => PgBoolReturn; + }; + readonly lt: { + readonly self: { readonly traits: readonly ['order'] }; + readonly impl: >( + a: CodecExpression, + b: CodecExpression, + ) => PgBoolReturn; + }; + readonly lte: { + readonly self: { readonly traits: readonly ['order'] }; + readonly impl: >( + a: CodecExpression, + b: CodecExpression, + ) => PgBoolReturn; + }; + + // Textual predicate — trait-gated + readonly like: { + readonly self: { readonly traits: readonly ['textual'] }; + readonly impl: >( + a: CodecExpression, + b: CodecExpression, + ) => PgBoolReturn; + }; + + // Null checks — any codec + readonly isNull: { + readonly self: { readonly any: true }; + readonly impl: (expr: Expression) => PgBoolReturn; + }; + readonly isNotNull: { + readonly self: { readonly any: true }; + readonly impl: (expr: Expression) => PgBoolReturn; + }; + + // Boolean composition — no `self` (sql-builder-only; not column methods) + readonly and: { + readonly impl: ( + ...exprs: ReadonlyArray> + ) => PgBoolReturn; + }; + readonly or: { + readonly impl: ( + ...exprs: ReadonlyArray> + ) => PgBoolReturn; + }; + readonly exists: { + readonly impl: (subquery: Subquery>) => PgBoolReturn; + }; + readonly notExists: { + readonly impl: (subquery: Subquery>) => PgBoolReturn; + }; + } +>; + +// Type-only re-export so the structural shape's constraint origin is +// discoverable from this module. +export type { QueryOperationTypeEntry }; diff --git a/packages/2-sql/9-family/test/codec-ids-with-trait.test-d.ts b/packages/2-sql/9-family/test/codec-ids-with-trait.test-d.ts new file mode 100644 index 0000000000..21debeeb21 --- /dev/null +++ b/packages/2-sql/9-family/test/codec-ids-with-trait.test-d.ts @@ -0,0 +1,110 @@ +/** + * Type-level regression test for the family-SQL `CodecIdsWithTrait` + * filter at `src/types/operation-types.ts`. + * + * The bug being pinned: the previous gate body + * (`[Trait] extends [T extends readonly string[] ? T[number] : never]`) + * required `T` (the codec's `traits` slot) to be a tuple. But + * `ExtractCodecTypes` in `relational-core/src/expression.ts` + * flattens descriptor `traits` tuples to string unions via + * `DescriptorCodecTraits = TTraits[number] & CodecTrait`. Every + * real-contract instantiation therefore presented `T` as a union, the + * tuple gate fell through to `never`, and `EqualityCodecId` / + * `OrderCodecId` / `TextualCodecId` cascaded to `never` for + * every real consumer. The downstream symptoms were the impl signature + * `>(...) => PgBoolReturn` + * resolving to `(...) => ...`, which made + * `fns.eq(intCol, 1)` and `column.eq(value)` reject every concrete arg + * with `Argument of type '1' is not assignable to parameter of type + * 'CodecExpression'`. + * + * The fixed gate body (`[RequiredTraits[number]] extends [T]`) handles + * both tuple and union `T`. This test pins the union-shaped input the + * real-contract case always presents, plus a couple of negative-case + * probes so a future "optimisation" that re-introduces the tuple-gate + * regression trips loudly. + */ + +import { describe, expectTypeOf, test } from 'vitest'; +import type { + EqualityCodecId, + OrderCodecId, + TextualCodecId, +} from '../src/exports/operation-types'; + +/** + * Codec-types fixture in the shape `ExtractCodecTypes` + * actually produces: each codec's `traits` slot is a string union + * (not a tuple). Covers a representative mix: + * + * - `pg/int4@1` declares `equality + order + numeric` (textual absent). + * - `pg/text@1` declares `equality + order + textual` (numeric absent). + * - `pg/bool@1` declares `equality + boolean` only (no order, no textual). + * - `cipherstash/string@1` declares only namespaced traits + * (`cipherstash:*`) -- no framework-canonical trait. + * + * Keeping the shape inline (rather than importing a generated contract) + * keeps this test fast to typecheck and independent of fixture drift. + */ +type TestCT = { + readonly 'pg/int4@1': { + readonly input: number; + readonly output: number; + readonly traits: 'equality' | 'order' | 'numeric'; + }; + readonly 'pg/text@1': { + readonly input: string; + readonly output: string; + readonly traits: 'equality' | 'order' | 'textual'; + }; + readonly 'pg/bool@1': { + readonly input: boolean; + readonly output: boolean; + readonly traits: 'equality' | 'boolean'; + }; + readonly 'cipherstash/string@1': { + readonly input: string; + readonly output: string; + readonly traits: 'cipherstash:equality' | 'cipherstash:textual'; + }; +}; + +describe('CodecIdsWithTrait filter resolves union-shaped traits (regression for the family-SQL tuple-gate bug)', () => { + test('EqualityCodecId includes every codec id declaring equality', () => { + expectTypeOf>().toEqualTypeOf< + 'pg/int4@1' | 'pg/text@1' | 'pg/bool@1' + >(); + }); + + test('OrderCodecId includes every codec id declaring order', () => { + expectTypeOf>().toEqualTypeOf<'pg/int4@1' | 'pg/text@1'>(); + }); + + test('TextualCodecId includes only codec ids declaring textual', () => { + expectTypeOf>().toEqualTypeOf<'pg/text@1'>(); + }); + + test('cipherstash-style codecs (only namespaced traits) are excluded from EqualityCodecId', () => { + type Eq = EqualityCodecId; + expectTypeOf<'cipherstash/string@1' extends Eq ? true : false>().toEqualTypeOf(); + }); + + test('cipherstash-style codecs are excluded from OrderCodecId', () => { + type Ord = OrderCodecId; + expectTypeOf<'cipherstash/string@1' extends Ord ? true : false>().toEqualTypeOf(); + }); + + test('cipherstash-style codecs are excluded from TextualCodecId', () => { + type Txt = TextualCodecId; + expectTypeOf<'cipherstash/string@1' extends Txt ? true : false>().toEqualTypeOf(); + }); + + test('no real-contract codec id cascades to never (the load-bearing symptom of the original bug)', () => { + // The original bug surfaced as `EqualityCodecId = never` for every + // real contract -- which then forced `` on the + // family impls and broke every consumer call site. Pin the negative. + expectTypeOf<[EqualityCodecId] extends [never] ? true : false>().toEqualTypeOf(); + expectTypeOf<[OrderCodecId] extends [never] ? true : false>().toEqualTypeOf(); + expectTypeOf<[TextualCodecId] extends [never] ? true : false>().toEqualTypeOf(); + }); +}); diff --git a/packages/2-sql/9-family/test/query-operations.test.ts b/packages/2-sql/9-family/test/query-operations.test.ts new file mode 100644 index 0000000000..e22ad816e0 --- /dev/null +++ b/packages/2-sql/9-family/test/query-operations.test.ts @@ -0,0 +1,341 @@ +import { type Contract, coreHash, profileHash } from '@prisma-next/contract/types'; +import type { AnyCodecDescriptor, CodecTrait } from '@prisma-next/framework-components/codec'; +import { extractQueryOperationTypeImports } from '@prisma-next/framework-components/control'; +import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +import { buildSqlNamespace, SqlStorage } from '@prisma-next/sql-contract/types'; +import { + createExecutionContext, + type SqlExecutionStack, + type SqlRuntimeAdapterDescriptor, + type SqlRuntimeAdapterInstance, + type SqlRuntimeTargetDescriptor, +} from '@prisma-next/sql-runtime'; +import { describe, expect, it } from 'vitest'; +import sqlFamilyControlDescriptor from '../src/exports/control'; +import sqlRuntimeFamilyDescriptor from '../src/exports/runtime'; + +/** + * Expected names of every operation `sqlFamilyOperations()` registers. Used + * by group 1 (exact-set probe) and group 4 (no-self ops). Sorted for + * deterministic comparison. + */ +const FAMILY_OP_NAMES = [ + 'and', + 'eq', + 'exists', + 'gt', + 'gte', + 'in', + 'isNotNull', + 'isNull', + 'like', + 'lt', + 'lte', + 'neq', + 'notExists', + 'notIn', + 'or', +] as const; + +const TRAIT_GATED_OP_NAMES = [ + 'eq', + 'neq', + 'in', + 'notIn', + 'gt', + 'gte', + 'lt', + 'lte', + 'like', +] as const; +const ANY_OP_NAMES = ['isNull', 'isNotNull'] as const; +const NO_SELF_OP_NAMES = ['and', 'or', 'exists', 'notExists'] as const; + +function emptyContract(): Contract { + return { + target: 'postgres', + targetFamily: 'sql', + profileHash: profileHash('sha256:family-ops-factory-test'), + storage: new SqlStorage({ + storageHash: coreHash('sha256:family-ops-factory-test'), + namespaces: { + [UNBOUND_NAMESPACE_ID]: buildSqlNamespace({ + id: UNBOUND_NAMESPACE_ID, + tables: {}, + }), + }, + }), + domain: { + namespaces: { + [UNBOUND_NAMESPACE_ID]: { models: {} }, + }, + }, + roots: {}, + capabilities: {}, + extensionPacks: {}, + meta: {}, + }; +} + +function codecDescriptor(codecId: string, traits: readonly CodecTrait[]): AnyCodecDescriptor { + return { + codecId, + traits, + targetTypes: [], + paramsSchema: { + '~standard': { + version: 1 as const, + vendor: 'family-sql/test', + validate: () => ({ value: undefined }), + }, + }, + isParameterized: false, + factory: () => () => { + throw new Error('test descriptor factory not exercised'); + }, + }; +} + +function targetDescriptor(): SqlRuntimeTargetDescriptor<'postgres'> { + return { + kind: 'target' as const, + id: 'postgres', + version: '0.0.1', + familyId: 'sql' as const, + targetId: 'postgres' as const, + codecs: () => [], + create() { + return { familyId: 'sql' as const, targetId: 'postgres' as const }; + }, + }; +} + +function adapterDescriptor( + codecs: readonly AnyCodecDescriptor[], +): SqlRuntimeAdapterDescriptor<'postgres'> { + return { + kind: 'adapter' as const, + id: 'test-adapter', + version: '0.0.1', + familyId: 'sql' as const, + targetId: 'postgres' as const, + rawCodecInferer: { inferCodec: () => 'pg/text@1' }, + codecs: () => codecs, + create(): SqlRuntimeAdapterInstance<'postgres'> { + return { + familyId: 'sql' as const, + targetId: 'postgres' as const, + profile: { + id: 'test-profile', + target: 'postgres', + capabilities: {}, + readMarker: async () => ({ kind: 'absent' as const }), + }, + lower: () => { + throw new Error('lower not exercised'); + }, + }; + }, + }; +} + +function stackWithCodecs(codecs: readonly AnyCodecDescriptor[]): SqlExecutionStack<'postgres'> { + return { + family: sqlRuntimeFamilyDescriptor, + target: targetDescriptor(), + adapter: adapterDescriptor(codecs), + extensionPacks: [], + }; +} + +/** + * Replicates the trait-expansion loop at + * `packages/3-extensions/sql-orm-client/src/model-accessor.ts:71-89` so the + * assertions in groups 2 / 3 / 4 operate on the same registry-level + * behavior the ORM accessor will read post-slice-3. + */ +function indexOpsByCodec( + entries: Readonly>, + descriptors: readonly AnyCodecDescriptor[], +): Map> { + const byCodec = new Map>(); + for (const descriptor of descriptors) { + byCodec.set(descriptor.codecId, new Set()); + } + for (const [name, entry] of Object.entries(entries)) { + const self = entry.self as + | { readonly codecId?: string; readonly traits?: readonly string[]; readonly any?: true } + | undefined; + if (!self) continue; + if (self.codecId !== undefined) { + byCodec.get(self.codecId)?.add(name); + } else if (self.traits !== undefined) { + for (const descriptor of descriptors) { + if (self.traits.every((t) => descriptor.traits.includes(t as CodecTrait))) { + byCodec.get(descriptor.codecId)?.add(name); + } + } + } else if (self.any === true) { + for (const descriptor of descriptors) { + byCodec.get(descriptor.codecId)?.add(name); + } + } + } + return byCodec; +} + +describe('sqlFamilyOperations integration', () => { + describe('direct registry probe', () => { + it('registers exactly the 15 family operations with no extras', () => { + const context = createExecutionContext({ + contract: emptyContract(), + stack: stackWithCodecs([]), + }); + + const names = Object.keys(context.queryOperations.entries()).sort(); + expect(names).toEqual([...FAMILY_OP_NAMES]); + }); + }); + + describe('trait-gated per-codec indexing', () => { + const int4 = codecDescriptor('pg/int4@1', ['equality', 'order']); + const text = codecDescriptor('pg/text@1', ['equality', 'order', 'textual']); + const cipherstashLike = codecDescriptor('cipherstash/string@1', []); + const descriptors = [int4, text, cipherstashLike]; + + function indexedOps() { + const context = createExecutionContext({ + contract: emptyContract(), + stack: stackWithCodecs(descriptors), + }); + return indexOpsByCodec(context.queryOperations.entries(), descriptors); + } + + it('indexes equality ops under codecs that declare the `equality` trait', () => { + const ops = indexedOps(); + for (const name of ['eq', 'neq', 'in', 'notIn'] as const) { + expect(ops.get('pg/int4@1')?.has(name)).toBe(true); + expect(ops.get('pg/text@1')?.has(name)).toBe(true); + expect(ops.get('cipherstash/string@1')?.has(name)).toBe(false); + } + }); + + it('indexes order ops under codecs that declare the `order` trait', () => { + const ops = indexedOps(); + for (const name of ['gt', 'gte', 'lt', 'lte'] as const) { + expect(ops.get('pg/int4@1')?.has(name)).toBe(true); + expect(ops.get('pg/text@1')?.has(name)).toBe(true); + expect(ops.get('cipherstash/string@1')?.has(name)).toBe(false); + } + }); + + it('indexes `like` under codecs that declare the `textual` trait only', () => { + const ops = indexedOps(); + expect(ops.get('pg/int4@1')?.has('like')).toBe(false); + expect(ops.get('pg/text@1')?.has('like')).toBe(true); + expect(ops.get('cipherstash/string@1')?.has('like')).toBe(false); + }); + + it('does not index any trait-gated op under a `traits: []` codec', () => { + const ops = indexedOps(); + const cipherstashOps = ops.get('cipherstash/string@1') ?? new Set(); + for (const name of TRAIT_GATED_OP_NAMES) { + expect(cipherstashOps.has(name)).toBe(false); + } + }); + }); + + describe('any:true per-codec indexing', () => { + it('indexes isNull / isNotNull under every codec including traits-empty ones', () => { + const descriptors = [ + codecDescriptor('pg/int4@1', ['equality', 'order']), + codecDescriptor('pg/text@1', ['equality', 'order', 'textual']), + codecDescriptor('cipherstash/string@1', []), + ]; + const context = createExecutionContext({ + contract: emptyContract(), + stack: stackWithCodecs(descriptors), + }); + const ops = indexOpsByCodec(context.queryOperations.entries(), descriptors); + + for (const descriptor of descriptors) { + const opsForCodec = ops.get(descriptor.codecId); + for (const name of ANY_OP_NAMES) { + expect(opsForCodec?.has(name)).toBe(true); + } + } + }); + }); + + describe('no-self ops not surfacing on per-codec index', () => { + it('keeps and / or / exists / notExists in the registry but off every per-codec index', () => { + const descriptors = [ + codecDescriptor('pg/int4@1', ['equality', 'order']), + codecDescriptor('cipherstash/string@1', []), + ]; + const context = createExecutionContext({ + contract: emptyContract(), + stack: stackWithCodecs(descriptors), + }); + const entries = context.queryOperations.entries(); + const ops = indexOpsByCodec(entries, descriptors); + + for (const name of NO_SELF_OP_NAMES) { + // Present in the registry… + expect(entries[name]).toBeDefined(); + // …with no `self` field (canonical no-self shape). + expect(entries[name]?.self).toBeUndefined(); + // …and absent from every codec's per-column index. + for (const descriptor of descriptors) { + expect(ops.get(descriptor.codecId)?.has(name)).toBe(false); + } + } + }); + }); + + describe('emitter alias-aggregation', () => { + it('exposes the family `QueryOperationTypes` import on the control descriptor', () => { + const slot = sqlFamilyControlDescriptor.types?.queryOperationTypes; + expect(slot).toBeDefined(); + expect(slot?.import).toEqual({ + package: '@prisma-next/family-sql/operation-types', + named: 'QueryOperationTypes', + alias: 'SqlFamilyQueryOperationTypes', + }); + }); + + it('flows the family slot through extractQueryOperationTypeImports', () => { + const imports = extractQueryOperationTypeImports([sqlFamilyControlDescriptor]); + expect(imports).toEqual([ + { + package: '@prisma-next/family-sql/operation-types', + named: 'QueryOperationTypes', + alias: 'SqlFamilyQueryOperationTypes', + }, + ]); + }); + + it('intersects family imports with adapter imports when both contribute', () => { + const fakeAdapterControlDescriptor = { + kind: 'adapter' as const, + id: 'pg-adapter', + version: '0.0.1', + types: { + queryOperationTypes: { + import: { + package: '@prisma-next/adapter-postgres/operation-types', + named: 'PgAdapterQueryOps', + alias: 'PgAdapterQueryOps', + }, + }, + }, + }; + const imports = extractQueryOperationTypeImports([ + sqlFamilyControlDescriptor, + fakeAdapterControlDescriptor, + ]); + const aliases = imports.map((i) => i.alias); + expect(aliases).toEqual(['SqlFamilyQueryOperationTypes', 'PgAdapterQueryOps']); + }); + }); +}); diff --git a/packages/2-sql/9-family/tsdown.config.ts b/packages/2-sql/9-family/tsdown.config.ts index 018eff4957..b27d73b915 100644 --- a/packages/2-sql/9-family/tsdown.config.ts +++ b/packages/2-sql/9-family/tsdown.config.ts @@ -6,6 +6,7 @@ export default defineConfig({ 'src/exports/control-adapter.ts', 'src/exports/ir.ts', 'src/exports/migration.ts', + 'src/exports/operation-types.ts', 'src/exports/pack.ts', 'src/exports/runtime.ts', 'src/exports/verify.ts', diff --git a/packages/3-extensions/paradedb/src/contract.d.ts b/packages/3-extensions/paradedb/src/contract.d.ts index ac65043946..d6866412a2 100644 --- a/packages/3-extensions/paradedb/src/contract.d.ts +++ b/packages/3-extensions/paradedb/src/contract.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated by 'prisma-next contract emit'. // To regenerate, run: prisma-next contract emit import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -38,7 +39,8 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps; +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/packages/3-extensions/pgvector/src/contract.d.ts b/packages/3-extensions/pgvector/src/contract.d.ts index e0b6f86320..14a0c21cd5 100644 --- a/packages/3-extensions/pgvector/src/contract.d.ts +++ b/packages/3-extensions/pgvector/src/contract.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated by 'prisma-next contract emit'. // To regenerate, run: prisma-next contract emit import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -38,7 +39,8 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps; +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/packages/3-extensions/postgis/src/contract.d.ts b/packages/3-extensions/postgis/src/contract.d.ts index 69b4cd129a..0acee31aa1 100644 --- a/packages/3-extensions/postgis/src/contract.d.ts +++ b/packages/3-extensions/postgis/src/contract.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated by 'prisma-next contract emit'. // To regenerate, run: prisma-next contract emit import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -38,7 +39,8 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps; +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/packages/3-extensions/postgres/src/runtime/postgres-serverless.ts b/packages/3-extensions/postgres/src/runtime/postgres-serverless.ts index 5d34ff792a..1f81552fd6 100644 --- a/packages/3-extensions/postgres/src/runtime/postgres-serverless.ts +++ b/packages/3-extensions/postgres/src/runtime/postgres-serverless.ts @@ -3,6 +3,7 @@ import type { Contract } from '@prisma-next/contract/types'; import postgresDriver, { type PostgresDriverCreateOptions, } from '@prisma-next/driver-postgres/runtime'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; import { sql as sqlBuilder } from '@prisma-next/sql-builder/runtime'; import type { Db } from '@prisma-next/sql-builder/types'; @@ -117,6 +118,7 @@ export default function postgresServerless { const contract = resolveContract(options); const stack = createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter, driver: postgresDriver, diff --git a/packages/3-extensions/postgres/src/runtime/postgres.ts b/packages/3-extensions/postgres/src/runtime/postgres.ts index b7c1064097..f60e756e50 100644 --- a/packages/3-extensions/postgres/src/runtime/postgres.ts +++ b/packages/3-extensions/postgres/src/runtime/postgres.ts @@ -1,6 +1,7 @@ import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; import type { Contract } from '@prisma-next/contract/types'; import postgresDriver from '@prisma-next/driver-postgres/runtime'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; import { sql as sqlBuilder } from '@prisma-next/sql-builder/runtime'; import type { Db } from '@prisma-next/sql-builder/types'; @@ -155,6 +156,7 @@ export default function postgres>( const contract = resolveContract(options); let binding = resolveOptionalPostgresBinding(options); const stack = createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter, driver: postgresDriver, diff --git a/packages/3-extensions/sql-orm-client/scripts/strip-pgvector-fixture.mjs b/packages/3-extensions/sql-orm-client/scripts/strip-pgvector-fixture.mjs index 6a2deb7d55..653e60cde1 100644 --- a/packages/3-extensions/sql-orm-client/scripts/strip-pgvector-fixture.mjs +++ b/packages/3-extensions/sql-orm-client/scripts/strip-pgvector-fixture.mjs @@ -26,6 +26,12 @@ if (!target) { const source = readFileSync(target, 'utf8'); +// The emitter aggregates one import line per pack contributing to +// `queryOperationTypes`. As of slice 2 the family-SQL pack contributes +// `SqlFamilyQueryOperationTypes`. Carry it through the strip verbatim — +// it doesn't participate in the +// `pgvector → postgres → sql-orm-client` cycle this script exists to +// break, so its only job here is to survive the replacement. const importBlock = [ "import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types';", 'import type {', @@ -33,10 +39,12 @@ const importBlock = [ ' Vector,', "} from '@prisma-next/extension-pgvector/codec-types';", "import type { QueryOperationTypes as PgVectorQueryOperationTypes } from '@prisma-next/extension-pgvector/operation-types';", + "import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types';", ].join('\n'); const replacement = [ "import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types';", + "import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types';", '// pgvector types replaced with local aliases (see note above)', 'type PgVectorTypes = object;', 'type Vector<_N extends number> = number[];', diff --git a/packages/3-extensions/sql-orm-client/src/exports/index.ts b/packages/3-extensions/sql-orm-client/src/exports/index.ts index e8261172ee..7ed5ce9914 100644 --- a/packages/3-extensions/sql-orm-client/src/exports/index.ts +++ b/packages/3-extensions/sql-orm-client/src/exports/index.ts @@ -12,7 +12,6 @@ export type { CollectionModelName, CollectionState, CollectionTypeState, - ComparisonMethods, CreateInput, DefaultCollectionTypeState, DefaultModelRow, diff --git a/packages/3-extensions/sql-orm-client/src/model-accessor.ts b/packages/3-extensions/sql-orm-client/src/model-accessor.ts index 2b3ec2c8eb..b2ce963af0 100644 --- a/packages/3-extensions/sql-orm-client/src/model-accessor.ts +++ b/packages/3-extensions/sql-orm-client/src/model-accessor.ts @@ -8,6 +8,7 @@ import { type CodecRef, ColumnRef, ExistsExpr, + OrderByItem, ProjectionItem, SelectAst, TableSource, @@ -22,12 +23,35 @@ import { resolveModelTableName, } from './collection-contract'; import { and, not } from './filters'; -import { - COMPARISON_METHODS_META, - type ComparisonMethodFns, - type ModelAccessor, - type RelationFilterAccessor, -} from './types'; +import type { ModelAccessor, RelationFilterAccessor } from './types'; + +/** + * Trait-gated `asc` / `desc` ordering factories. + * + * **Removed by slice 3b** — when the ORM ordering registry lands, the + * `OrderByModelAccessor` will own these factories and the WHERE accessor + * will no longer surface them. Until then, they live here so that the + * single registry-driven loop in `createScalarFieldAccessor` (and the + * mirror loop in `createExtensionMethodFactory` for non-predicate + * chained results) can attach `m.field.asc()` / `m.field.desc()` + * alongside the family-SQL registry's trait-gated predicates. + * + * The framework registry intentionally excludes ordering ops (they're + * an ORM concern, not a SQL-builder one). The `'order'` trait gate + * here mirrors the gate the deleted `COMPARISON_METHODS_META.asc / + * .desc` carried so the WHERE-accessor surface is byte-identical for + * order-trait codecs. + */ +const LEGACY_ORDERING_METHODS = { + asc: { + traits: ['order'] as const, + create: (left: AnyExpression) => () => OrderByItem.asc(left), + }, + desc: { + traits: ['order'] as const, + create: (left: AnyExpression) => () => OrderByItem.desc(left), + }, +} as const; type ResolvedModelRelation = ReturnType[string]; @@ -46,32 +70,7 @@ export function createModelAccessor< const tableName = resolveModelTableName(contract, modelName); const modelRelations = resolveModelRelations(contract, modelName); - const opsByCodecId = new Map(); - - function registerOp(codecId: string, op: NamedOp) { - let existing = opsByCodecId.get(codecId); - if (!existing) { - existing = []; - opsByCodecId.set(codecId, existing); - } - existing.push(op); - } - - for (const [name, entry] of Object.entries(context.queryOperations.entries())) { - const op: NamedOp = [name, entry]; - const self = entry.self; - if (!self) continue; - if (self.codecId !== undefined) { - registerOp(self.codecId, op); - } else if (self.traits !== undefined) { - for (const descriptor of context.codecDescriptors.values()) { - const descriptorTraits: readonly string[] = descriptor.traits; - if (self.traits.every((t) => descriptorTraits.includes(t))) { - registerOp(descriptor.codecId, op); - } - } - } - } + const opsByCodecId = buildOpsByCodecId(context); return new Proxy({} as ModelAccessor, { get(_target, prop: string | symbol): unknown { @@ -105,12 +104,59 @@ export function createModelAccessor< codec, traits, operations, + opsByCodecId, context, ); }, }); } +/** + * Build the per-codec operations index from the execution context's + * registry. For each registered operation, walks the contract's codec + * descriptors and registers the op against every codec id that + * satisfies its `self` dispatch hint (codec-id match, trait subset, or + * unconditional `any`). + * + * The index is shared by `createScalarFieldAccessor` (column-method + * synthesis) and `createExtensionMethodFactory` (chained-result + * synthesis on a non-predicate registry op's return codec). + */ +function buildOpsByCodecId(context: ExecutionContext): Map { + const opsByCodecId = new Map(); + + function registerOp(codecId: string, op: NamedOp) { + let existing = opsByCodecId.get(codecId); + if (!existing) { + existing = []; + opsByCodecId.set(codecId, existing); + } + existing.push(op); + } + + for (const [name, entry] of Object.entries(context.queryOperations.entries())) { + const op: NamedOp = [name, entry]; + const self = entry.self; + if (!self) continue; + if (self.codecId !== undefined) { + registerOp(self.codecId, op); + } else if (self.traits !== undefined) { + for (const descriptor of context.codecDescriptors.values()) { + const descriptorTraits: readonly string[] = descriptor.traits; + if (self.traits.every((t) => descriptorTraits.includes(t))) { + registerOp(descriptor.codecId, op); + } + } + } else if (self.any === true) { + for (const descriptor of context.codecDescriptors.values()) { + registerOp(descriptor.codecId, op); + } + } + } + + return opsByCodecId; +} + function resolveColumn( contract: Contract, tableName: string, @@ -132,32 +178,51 @@ function createScalarFieldAccessor( codec: CodecRef | undefined, traits: readonly string[], operations: readonly NamedOp[], + opsByCodecId: ReadonlyMap, context: ExecutionContext, -): Partial> { +): Expression & Record { const column = ColumnRef.of(tableName, columnName); - const comparisonEntries: Array<[string, unknown]> = []; - for (const [name, meta] of Object.entries(COMPARISON_METHODS_META)) { - if (meta.traits.some((t) => !traits.includes(t))) continue; - comparisonEntries.push([name, meta.create(column, codec)]); - } - + // `codec` may be undefined when the scope was built without contract + // storage; `ScopeField['codec']` is exact-optional, so we keep the + // legacy `as` cast rather than threading a conditional spread. const accessor = { returnType: { codecId, nullable, codec }, codec, buildAst: () => column, - ...Object.fromEntries(comparisonEntries), } as Expression & Record; + attachOperationMethods(accessor, column, traits, operations, opsByCodecId, context); + return accessor; +} +/** + * Single registry-driven synthesis loop: attaches each registry op + * applicable to this codec id as an extension-method factory, then + * layers on the transient `LEGACY_ORDERING_METHODS` (`asc` / `desc`) + * gated on the codec's trait set. Shared by `createScalarFieldAccessor` + * (column accessor) and `createExtensionMethodFactory` (chained-result + * accessor on a non-predicate op's return codec). + */ +function attachOperationMethods( + accessor: Expression & Record, + ast: AnyExpression, + traits: readonly string[], + operations: readonly NamedOp[], + opsByCodecId: ReadonlyMap, + context: ExecutionContext, +): void { for (const [name, entry] of operations) { - accessor[name] = createExtensionMethodFactory(accessor, entry, context); + accessor[name] = createExtensionMethodFactory(accessor, entry, opsByCodecId, context); + } + for (const [name, factory] of Object.entries(LEGACY_ORDERING_METHODS)) { + if (factory.traits.some((t) => !traits.includes(t))) continue; + accessor[name] = factory.create(ast); } - - return accessor as Partial>; } function createExtensionMethodFactory( selfExpr: Expression, entry: SqlOperationEntry, + opsByCodecId: ReadonlyMap, context: ExecutionContext, ): (...args: unknown[]) => unknown { return (...args: unknown[]) => { @@ -176,14 +241,22 @@ function createExtensionMethodFactory( return result.buildAst(); } + // Non-predicate result: build a sub-accessor whose method surface + // is sourced from the registry's per-result-codec ops index, layered + // with `LEGACY_ORDERING_METHODS`. This mirrors the column-accessor + // synthesis above so the chained surface (e.g. + // `column.cosineSimilarity(v).gt(0.5)` / + // `column.cosineSimilarity(v).desc()`) keeps working. const resultAst = result.buildAst(); const returnCodec: CodecRef = { codecId: returnCodecId }; - const methods: Record = {}; - for (const [resultMethodName, meta] of Object.entries(COMPARISON_METHODS_META)) { - if (meta.traits.some((t) => !returnTraits.includes(t))) continue; - methods[resultMethodName] = meta.create(resultAst, returnCodec); - } - return methods; + const subAccessor = { + returnType: { codecId: returnCodecId, nullable: false, codec: returnCodec }, + codec: returnCodec, + buildAst: () => resultAst, + } as Expression & Record; + const resultOps = opsByCodecId.get(returnCodecId) ?? []; + attachOperationMethods(subAccessor, resultAst, returnTraits, resultOps, opsByCodecId, context); + return subAccessor; }; } @@ -290,9 +363,12 @@ function toRelationWhereExpr>( continue; } - const fieldAccessor = (accessor as Record>>)[ - fieldName - ]; + const fieldAccessor = (accessor as Record)[fieldName] as + | { + eq?: (value: unknown) => AnyExpression; + isNull?: () => AnyExpression; + } + | undefined; // Unknown field in the shorthand predicate — the Proxy returns undefined // for fields the contract doesn't declare. Surface it explicitly: silent // skip would drop user intent (e.g. a typo'd `nmae: 'Alice'` filter would diff --git a/packages/3-extensions/sql-orm-client/src/types.ts b/packages/3-extensions/sql-orm-client/src/types.ts index 7726116073..57a2a08307 100644 --- a/packages/3-extensions/sql-orm-client/src/types.ts +++ b/packages/3-extensions/sql-orm-client/src/types.ts @@ -7,17 +7,7 @@ import type { StorageColumn, StorageTable, } from '@prisma-next/sql-contract/types'; -import { - type AnyExpression, - BinaryExpr, - type BinaryOp, - type CodecRef, - type CodecTrait, - ListExpression, - NullCheckExpr, - OrderByItem, - ParamRef, -} from '@prisma-next/sql-relational-core/ast'; +import type { AnyExpression, OrderByItem } from '@prisma-next/sql-relational-core/ast'; import type { Expression } from '@prisma-next/sql-relational-core/expression'; import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; import type { ComputeColumnJsType, RuntimeScope } from '@prisma-next/sql-relational-core/types'; @@ -135,67 +125,44 @@ export interface CollectionContext> { readonly context: ExecutionContext; } -export type ComparisonMethodFns = { - eq(value: T): AnyExpression; - neq(value: T): AnyExpression; - gt(value: T): AnyExpression; - lt(value: T): AnyExpression; - gte(value: T): AnyExpression; - lte(value: T): AnyExpression; - like(pattern: string): AnyExpression; - in(values: readonly T[]): AnyExpression; - notIn(values: readonly T[]): AnyExpression; - isNull(): AnyExpression; - isNotNull(): AnyExpression; - asc(): OrderByItem; - desc(): OrderByItem; -}; - /** - * Trait-gated comparison methods. Only methods whose required traits are all present in `Traits` are included. - * - * - `traits: []` → always available (isNull, isNotNull) + * Trait-gated `asc` / `desc` ordering-method surface — the type-level + * mirror of the runtime `LEGACY_ORDERING_METHODS` map in + * `./model-accessor.ts`. Slice 3b removes both halves of the pair when + * the ORM ordering registry lands and `asc` / `desc` move to a + * dedicated `OrderByModelAccessor`. Until then, both the column + * accessor and the chained-result accessor intersect this onto the + * registry-derived method surface so `m.field.asc()` and + * `column.someOp(...).desc()` continue to typecheck on `order`-trait + * codecs. */ -export type ComparisonMethods = { - [K in keyof ComparisonMethodsMeta as [ComparisonMethodsMeta[K]['traits'][number]] extends [Traits] - ? K - : never]: ComparisonMethodFns[K]; -}; - -type QueryOperationReturnTraits< - Returns, - TCodecTypes extends Record, -> = Returns extends { readonly codecId: infer Id extends string } - ? Id extends keyof TCodecTypes - ? TCodecTypes[Id] extends { readonly traits: infer Traits } - ? Traits - : never - : never - : never; - -type QueryOperationReturnJsType< - Returns, - TCodecTypes extends Record, -> = Returns extends { readonly codecId: infer Id extends string; readonly nullable: infer N } - ? Id extends keyof TCodecTypes - ? TCodecTypes[Id] extends { readonly output: infer O } - ? N extends true - ? O | null - : O - : unknown - : unknown - : unknown; +type LegacyOrderingMethods = 'order' extends Traits + ? { + asc(): OrderByItem; + desc(): OrderByItem; + } + : Record; type IsBooleanReturn> = Returns extends { readonly codecId: infer Id extends string; } - ? Id extends keyof TCodecTypes - ? TCodecTypes[Id] extends { readonly traits: infer T } - ? 'boolean' extends T - ? true + ? // Family-SQL hardcodes `pg/bool@1` as the predicate-return codec id on + // every family op (`eq`, `gt`, `like`, `isNull`, etc.) in + // `family-sql/src/types/operation-types.ts`. Treat it as a universal + // boolean marker so the predicate-return branch fires on every SQL + // target — including SQLite, whose `CodecTypes` does not include + // `pg/bool@1`. The marker-recognition is target-neutral: Postgres + // contracts still resolve through the trait-check below, and the two + // branches agree. + Id extends 'pg/bool@1' + ? true + : Id extends keyof TCodecTypes + ? TCodecTypes[Id] extends { readonly traits: infer T } + ? 'boolean' extends T + ? true + : false : false : false - : false : false; /** @@ -209,42 +176,86 @@ type ImplReturnSpec = Impl extends (...args: never[]) => infer Ret ? SpecO * Builds the ORM column-method signature for an operation. * * - User args: drops the impl's first parameter (the column is bound at access time) and forwards the rest unchanged. Each remaining arg keeps its authored `CodecExpression` / `TraitExpression` shape — so callers can pass a raw JS value, another column handle (which itself implements `Expression`), or `null` when nullable. - * - Return: predicate ops (boolean-traited return) yield `AnyExpression`; non-predicate ops yield `ComparisonMethods` of the return codec. + * - Return: predicate ops (boolean-traited return) yield `AnyExpression`; non-predicate ops yield `ChainedResultMethods`, deriving the chained-result surface from the same registry the column accessor reads. + * + * The third type parameter `Ops` carries the full registry (the + * contract's `queryOperationTypes` map) so the chained-result branch + * can iterate it the same way `FieldOperations` does for the column. */ -type QueryOperationMethod> = Op extends { +type QueryOperationMethod< + Op, + TCodecTypes extends Record, + Ops, +> = Op extends { readonly impl: (...args: never[]) => unknown; } ? Op['impl'] extends (first: never, ...rest: infer UserArgs extends readonly unknown[]) => unknown ? ImplReturnSpec extends infer Returns ? IsBooleanReturn extends true ? (...args: UserArgs) => AnyExpression - : ( - ...args: UserArgs - ) => ComparisonMethods< - QueryOperationReturnJsType, - QueryOperationReturnTraits - > + : (...args: UserArgs) => ChainedResultMethods : never : never : never; /** - * Tests whether an operation's `self` dispatch hint reaches a field with the given codec identity. Codec hints match by identity; trait hints match when every required trait is present in the field codec's trait set. + * Chained-result method surface for a non-predicate registry operation. + * + * Mirrors the column-accessor derivation pattern: given the return + * codec spec (`{ codecId, nullable }`) and the host contract's + * codec-types map + op registry, iterates the registry filtered by + * `OpMatchesField` against the return codec's identity and trait set + * — the same filter the column case uses. Intersected with the + * transient `LegacyOrderingMethods` so `asc` / `desc` + * survive on chained results of `order`-trait codecs (slice 3b's + * ordering-registry deletion target). + * + * The recursive `QueryOperationMethod` + * terminates naturally for the family-SQL registry today: every + * trait-gated entry returns `pg/bool@1` (a predicate), so the + * `IsBooleanReturn` branch fires on the first chain step and yields + * `AnyExpression` rather than another `ChainedResultMethods`. + */ +type ChainedResultMethods< + Returns, + TCodecTypes extends Record, + Ops, +> = Returns extends { readonly codecId: infer Id extends string } + ? Id extends keyof TCodecTypes + ? TCodecTypes[Id] extends { readonly traits: infer ReturnTraits } + ? { + [OpName in keyof Ops & string as OpMatchesField< + Ops[OpName], + Id, + TCodecTypes + > extends true + ? OpName + : never]: QueryOperationMethod; + } & LegacyOrderingMethods + : unknown + : unknown + : unknown; + +/** + * Tests whether an operation's `self` dispatch hint reaches a field with the given codec identity. The `any: true` arm matches every field codec; codec hints match by identity; trait hints match when every required trait is present in the field codec's trait set. */ type OpMatchesField> = Op extends { readonly self: infer Self; } - ? Self extends { readonly codecId: CodecId } + ? // `any: true` is the most permissive arm; handled first for documentation-of-intent, not for correctness — the discriminated union ensures mutual exclusion. + Self extends { readonly any: true } ? true - : Self extends { readonly traits: infer RequiredTraits extends readonly string[] } - ? CodecId extends keyof CT - ? CT[CodecId] extends { readonly traits: infer FieldTraits } - ? [RequiredTraits[number]] extends [FieldTraits] - ? true + : Self extends { readonly codecId: CodecId } + ? true + : Self extends { readonly traits: infer RequiredTraits extends readonly string[] } + ? CodecId extends keyof CT + ? CT[CodecId] extends { readonly traits: infer FieldTraits } + ? [RequiredTraits[number]] extends [FieldTraits] + ? true + : false : false : false : false - : false : false; type FieldOperations< @@ -261,107 +272,12 @@ type FieldOperations< ExtractCodecTypes > extends true ? OpName - : never]: QueryOperationMethod>; + : never]: QueryOperationMethod, AllOps>; } : unknown : unknown; -function param(codec: CodecRef | undefined, value: unknown): ParamRef { - if (codec === undefined) return ParamRef.of(value); - return ParamRef.of(value, { codec }); -} - -function paramList(codec: CodecRef | undefined, values: readonly unknown[]): ListExpression { - return ListExpression.of(values.map((value) => param(codec, value))); -} -// never[] is intentional: factories have heterogeneous signatures (value: unknown, values: readonly unknown[], pattern: string, etc.) but are only called through the typed ComparisonMethodFns interface, never through this type directly. -type MethodFactory = ( - left: AnyExpression, - codec: CodecRef | undefined, -) => (...args: never[]) => unknown; - -type ComparisonMethodMeta = { - readonly traits: readonly CodecTrait[]; - readonly create: MethodFactory; -}; - -function scalarComparisonMethod(op: BinaryOp) { - return ((left, codec) => (value: unknown) => { - if (value === null && (op === 'eq' || op === 'neq')) { - return op === 'eq' ? NullCheckExpr.isNull(left) : NullCheckExpr.isNotNull(left); - } - return new BinaryExpr(op, left, param(codec, value)); - }) satisfies MethodFactory; -} - -function listComparisonMethod(op: BinaryOp) { - return ((left, codec) => (values: readonly unknown[]) => - new BinaryExpr(op, left, paramList(codec, values))) satisfies MethodFactory; -} - -/** - * Declares trait requirements and runtime factory for each comparison method. - * - * - `traits: []` means "no trait required" — always available - * - Multi-trait: `traits: ['equality', 'order']` means BOTH traits are required - */ -export const COMPARISON_METHODS_META = { - eq: { - traits: ['equality'], - create: scalarComparisonMethod('eq'), - }, - neq: { - traits: ['equality'], - create: scalarComparisonMethod('neq'), - }, - in: { - traits: ['equality'], - create: listComparisonMethod('in'), - }, - notIn: { - traits: ['equality'], - create: listComparisonMethod('notIn'), - }, - gt: { - traits: ['order'], - create: scalarComparisonMethod('gt'), - }, - lt: { - traits: ['order'], - create: scalarComparisonMethod('lt'), - }, - gte: { - traits: ['order'], - create: scalarComparisonMethod('gte'), - }, - lte: { - traits: ['order'], - create: scalarComparisonMethod('lte'), - }, - like: { - traits: ['textual'], - create: scalarComparisonMethod('like'), - }, - asc: { - traits: ['order'], - create: (left) => () => OrderByItem.asc(left), - }, - desc: { - traits: ['order'], - create: (left) => () => OrderByItem.desc(left), - }, - isNull: { - traits: [], - create: (left) => () => NullCheckExpr.isNull(left), - }, - isNotNull: { - traits: [], - create: (left) => () => NullCheckExpr.isNotNull(left), - }, -} as const satisfies Record, ComparisonMethodMeta>; - -type ComparisonMethodsMeta = typeof COMPARISON_METHODS_META; export type RelationPredicate, ModelName extends string> = ( model: ModelAccessor, @@ -386,8 +302,8 @@ type ScalarModelAccessor, ModelName exten codecId: FieldCodecId; nullable: FieldNullable; }> & - ComparisonMethods, FieldTraits> & - FieldOperations; + FieldOperations & + LegacyOrderingMethods>; }; type RelationModelAccessor, ModelName extends string> = { @@ -492,10 +408,14 @@ export interface AggregateBuilder< ): AggregateSelector; } -export type HavingComparisonMethods = Pick< - ComparisonMethods, - 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte' ->; +export type HavingComparisonMethods = { + eq(value: T): AnyExpression; + neq(value: T): AnyExpression; + gt(value: T): AnyExpression; + lt(value: T): AnyExpression; + gte(value: T): AnyExpression; + lte(value: T): AnyExpression; +}; export interface HavingBuilder, ModelName extends string> { count(): HavingComparisonMethods; diff --git a/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts b/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts index cee860f56b..856bbafbb5 100644 --- a/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts +++ b/packages/3-extensions/sql-orm-client/test/fixtures/generated/contract.d.ts @@ -7,6 +7,7 @@ // (@prisma-next/extension-pgvector → @prisma-next/postgres → @prisma-next/sql-orm-client) // The integration tests that exercise pgvector-specific operations live in test/integration/. import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; // pgvector types replaced with local aliases (see note above) type PgVectorTypes = object; type Vector<_N extends number> = number[]; @@ -48,7 +49,8 @@ export type ProfileHash = export type CodecTypes = PgTypes & PgVectorTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps & +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps & PgVectorQueryOperationTypes; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] diff --git a/packages/3-extensions/sql-orm-client/test/generated-contract-types.test-d.ts b/packages/3-extensions/sql-orm-client/test/generated-contract-types.test-d.ts index 5f8ef9399f..6647d31c44 100644 --- a/packages/3-extensions/sql-orm-client/test/generated-contract-types.test-d.ts +++ b/packages/3-extensions/sql-orm-client/test/generated-contract-types.test-d.ts @@ -1,20 +1,29 @@ import type { Contract, NamespaceId, StorageHashBase } from '@prisma-next/contract/types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { ContractWithTypeMaps, SqlStorage, TypeMaps } from '@prisma-next/sql-contract/types'; import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; import { Collection } from '../src/collection'; import { createMockRuntime } from './helpers'; +// Codec-types fixture matches the shape `ExtractCodecTypes` +// presents to the column-method derivation: each codec carries `input` +// and `output` JS types plus a union of trait names. `pg/bool@1` is +// included because the family-SQL registry's predicate ops return it; +// without it the `IsBooleanReturn` check downstream can't resolve. type GeneratedLikeCodecTypes = { 'pg/text@1': { + input: string; output: string; traits: 'equality' | 'order' | 'textual'; }; 'pg/bool@1': { + input: boolean; output: boolean; traits: 'equality' | 'boolean'; }; 'pg/jsonb@1': { + input: unknown; output: unknown; traits: 'equality'; }; @@ -35,9 +44,19 @@ type GeneratedLikeFieldOutputTypes = { }; }; +// Query-operation types come straight from the SQL family registry -- +// mirrors the shape a real generated `Contract.d.ts` carries post-D1 +// (the family pack contributes `SqlFamilyQueryOperationTypes` into +// the contract's `queryOperationTypes` aggregation alongside the +// adapter and any extension packs). Post-R2 the column-method surface +// derives from this slot via `FieldOperations`, so the fixture has to +// declare it -- the `Record` placeholder this fixture +// carried under R1 only worked because `ScalarModelAccessor`'s +// hand-mirrored `ComparisonMethods` intersection used to +// supply the column methods independently. type GeneratedLikeTypeMaps = TypeMaps< GeneratedLikeCodecTypes, - Record, + SqlFamilyQueryOperationTypes, GeneratedLikeFieldOutputTypes >; diff --git a/packages/3-extensions/sql-orm-client/test/helpers.ts b/packages/3-extensions/sql-orm-client/test/helpers.ts index 26fee9d55a..bc20c7054a 100644 --- a/packages/3-extensions/sql-orm-client/test/helpers.ts +++ b/packages/3-extensions/sql-orm-client/test/helpers.ts @@ -1,6 +1,7 @@ import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; import { contractModels, type Contract as FrameworkContract } from '@prisma-next/contract/types'; import { SqlContractSerializer } from '@prisma-next/family-sql/ir'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import type { CodecDescriptor, CodecInstanceContext, @@ -138,6 +139,7 @@ const pgVectorCodecStubExtension: SqlRuntimeExtensionDescriptor<'postgres'> = (( const testContext: ExecutionContext = createExecutionContext({ contract: baseTestContract, stack: createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter, extensionPacks: [pgVectorCodecStubExtension], diff --git a/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts b/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts index 871d0028bb..97f6d5a8e1 100644 --- a/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts +++ b/packages/3-extensions/sql-orm-client/test/model-accessor.test.ts @@ -2,6 +2,7 @@ import { createSqlOperationRegistry } from '@prisma-next/sql-operations'; import type { CodecTrait } from '@prisma-next/sql-relational-core/ast'; import { AndExpr, + type AnyExpression, BinaryExpr, ColumnRef, ExistsExpr, @@ -375,11 +376,20 @@ describe('createModelAccessor', () => { }, })); - expect( - createModelAccessor({ ...context, contract: modelNameFallbackContract } as never, 'User')[ - 'name' - ]!.isNull(), - ).toEqual(NullCheckExpr.isNull(ColumnRef.of('users', 'name'))); + // `as never` collapses TContract to its constraint here, so the + // resulting accessor's per-field shape comes from a mapped type + // whose keys TS treats as an index signature. Mirror the existing + // `as unknown as Record` precedent further up in this + // file (the relation-fallback case) to project the accessor onto + // the minimal callable shape this assertion needs. + type IsNullCallable = { isNull(): AnyExpression }; + const accessor = createModelAccessor( + { ...context, contract: modelNameFallbackContract } as never, + 'User', + ) as unknown as Record; + expect(accessor['name']!.isNull()).toEqual( + NullCheckExpr.isNull(ColumnRef.of('users', 'name')), + ); }); it('combines relation shorthand fields with and() and rejects missing join arrays', () => { @@ -504,5 +514,35 @@ describe('createModelAccessor', () => { const views = post['views'] as unknown as Record; expect(views['synthetic']).toBeUndefined(); }); + + it('attaches any-targeted op to every column regardless of codec traits', () => { + const queryOperations = createSqlOperationRegistry(); + queryOperations.register('universalProbe', { + self: { any: true }, + impl: () => undefined as never, + }); + + const traitsByCodec: Record = { + 'pg/text@1': ['equality', 'order', 'textual'], + 'pg/int4@1': ['equality', 'order', 'numeric'], + 'pg/bool@1': ['equality', 'boolean'], + 'pg/jsonb@1': [], + }; + const codecDescriptors = makeDescriptors(traitsByCodec); + + const ctx = { ...context, queryOperations, codecDescriptors }; + const user = createModelAccessor(ctx, 'User'); + const post = createModelAccessor(ctx, 'Post'); + + // Columns spanning every trait set in the fixture: rich text traits, + // numeric, and zero-trait jsonb. The op must appear on each. + const name = user['name'] as unknown as Record; + const views = post['views'] as unknown as Record; + const address = user['address'] as unknown as Record; + + expect(typeof name['universalProbe']).toBe('function'); + expect(typeof views['universalProbe']).toBe('function'); + expect(typeof address['universalProbe']).toBe('function'); + }); }); }); diff --git a/packages/3-extensions/sqlite/src/runtime/sqlite.ts b/packages/3-extensions/sqlite/src/runtime/sqlite.ts index 05b218918b..4c19eba72e 100644 --- a/packages/3-extensions/sqlite/src/runtime/sqlite.ts +++ b/packages/3-extensions/sqlite/src/runtime/sqlite.ts @@ -3,6 +3,7 @@ import type { Contract } from '@prisma-next/contract/types'; import type { SqliteBinding } from '@prisma-next/driver-sqlite/runtime'; import sqliteDriver from '@prisma-next/driver-sqlite/runtime'; import { SqlContractSerializer } from '@prisma-next/family-sql/ir'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; import { sql as sqlBuilder } from '@prisma-next/sql-builder/runtime'; import type { Db } from '@prisma-next/sql-builder/types'; @@ -102,6 +103,7 @@ export default function sqlite>( const contract = resolveContract(options); let binding = resolveOptionalSqliteBinding(options); const stack = createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: sqliteTarget, adapter: sqliteAdapter, driver: sqliteDriver, diff --git a/projects/unify-query-operations/plan.md b/projects/unify-query-operations/plan.md new file mode 100644 index 0000000000..b0bb95d97e --- /dev/null +++ b/projects/unify-query-operations/plan.md @@ -0,0 +1,71 @@ +# Project Plan: unify-query-operations + +**Spec:** `projects/unify-query-operations/spec.md` +**Linear issue:** TML-2354 (single tracking issue — no sub-issues per slice; each slice PR references TML-2354 in its title/body so the GitHub integration links them). +**Purpose** _(from spec)_: Unify built-in and extension query operations behind a single SQL-family operation registry shipped by `@prisma-next/family-sql`. Delete `COMPARISON_METHODS_META` (ORM) and `BuiltinFunctions` (sql-builder); both authoring surfaces source every operation — common or not — from the same registry. Trait-target `self` so a codec's declared traits determine reachability uniformly across surfaces. + +## At a glance + +Five slices, **all stack** (each depends on the previous). One foundation slice (`SelfSpec.any` arm) unlocks the family factory + wiring slice, which unlocks the consumer-collapse slice (the big one — deletes both legacy surfaces and splits the orderBy callback accessor in the same atomic change). A small HAVING-derivation slice follows, then the ADR close-out. No parallel groups: the project is a linear stack because each slice removes/replaces code the next one touches. + +## Composition + +### Stack (deliver in order) + +1. **Slice `self-any-arm`** — Add the `{ any: true }` third arm to `SelfSpec`, extend the registration validator, the ORM model accessor's `self` resolution loop, and the type-level `OpMatchesField` matcher. Foundation: no user-visible surface change yet; just makes "applies to every codec" expressible. Scope: `packages/1-framework/1-core/operations/src/index.ts`, `packages/3-extensions/sql-orm-client/src/{model-accessor.ts,types.ts}`. Linear: TML-2354 (no sub-issue). Depends on: none. +2. **Slice `family-ops-factory`** — Ship `sqlFamilyOperations()` in `@prisma-next/family-sql` covering all 15 family operations (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `like`, `in`, `notIn`, `isNull`, `isNotNull`, `and`, `or`, `exists`, `notExists`). Trait-target `self` per the spec table. Wire the family pack as a fourth contributor in `createExecutionContext` so `queryOperations()` fires. Add the family's `queryOperationTypes` to the emitter alias-aggregation step so `Contract['queryOperationTypes']` includes them. At end of slice the registry contains the entries but neither consumer reads them yet — `COMPARISON_METHODS_META` and `BuiltinFunctions` still take precedence (registry entries are inert backups). Scope: `packages/2-sql/9-family/src/core/`, `packages/2-sql/9-family/src/exports/`, `packages/2-sql/5-runtime/src/sql-context.ts`, `packages/2-sql/3-tooling/emitter/src/index.ts`, family-sql tests. Linear: TML-2354 (no sub-issue). Depends on: slice 1 (uses `self: { any: true }` for `isNull`/`isNotNull`). +3. **Slice `collapse-consumers`** _(narrowed per operator decision 2026-05-21 — orderBy/WHERE accessor split deferred to slice 3b)_ — Delete `COMPARISON_METHODS_META`, `ComparisonMethodFns`, `BuiltinFunctions`, `createBuiltinFunctions`. Collapse the ORM model accessor's two-loop synthesis to a single registry loop. Drop the `BuiltinFunctions &` intersection from `Functions` so `fns` derives purely from `DeriveExtFunctions`. Migrate any `fns.ne` consumers to `fns.neq` (family uses `neq` per slice 2's decision). The user-visible trait tightening for cipherstash on `fns.eq` lands here (per spec § "Side-effect: trait gating becomes uniform"). The cipherstash `equality-trait-removal.test.ts` doc-comment that references `COMPARISON_METHODS_META` updates in the same diff. **Transient state after slice 3**: the WHERE-style column accessor still leaks cosmetic `.asc`/`.desc` because the orderBy accessor split is deferred — cipherstash's `fns.eq` already fails type-checking but the column accessor exposes `.asc`/`.desc` until slice 3b lands. Scope: `packages/3-extensions/sql-orm-client/src/{model-accessor.ts,types.ts,collection.ts}`, `packages/2-sql/4-lanes/sql-builder/src/{expression.ts,runtime/functions.ts}`, `packages/3-extensions/cipherstash/test/equality-trait-removal.test.ts`, plus any consumer of `fns.ne`. Linear: TML-2354 (no sub-issue). Depends on: slice 2 (the registry must already carry the family entries before the consumers can read them). +3b. **Slice `orderby-accessor-split`** _(new — split from original slice 3 per operator decision 2026-05-21)_ — Introduce the **ORM ordering registry** (private to `sql-orm-client`) with `asc`/`desc`, using the same `SqlOperationDescriptor` shape and trait-targeted matching as the SQL family registry. Split the `orderBy` callback accessor from the WHERE-style column accessor — `orderBy` selectors receive an `OrderByModelAccessor` that only carries `asc`/`desc`; WHERE accessors lose the cosmetic `.asc/.desc` leak that slice 3 leaves in place. Scope: `packages/3-extensions/sql-orm-client/src/{model-accessor.ts,types.ts,collection.ts}` (new orderBy accessor + WHERE accessor narrowing + types). Linear: TML-2354 (no sub-issue). Depends on: slice 3 (the legacy surfaces must be gone before introducing the new ordering registry; otherwise the WHERE accessor's narrowing wouldn't make sense with `COMPARISON_METHODS_META` still active). +4. **Slice `derive-having-surface`** — Delete `HavingComparisonMethods` (the hand-listed `Pick<…>`). Derive the HAVING aggregate selector's method surface from the SQL family registry by the predicate-return filter rule (FR22): ops whose `self` matches the aggregate's return codec **and** whose return codec carries the `boolean` trait. The net surface on numeric aggregates widens to `eq | neq | in | notIn | gt | lt | gte | lte | isNull | isNotNull` (a deliberate, documented widening). Add type-level tests. Scope: `packages/3-extensions/sql-orm-client/src/types.ts`, the `HavingBuilder` consumer sites, type-level tests. Linear: TML-2354 (no sub-issue). Depends on: slice 3b (the new orderBy accessor must be in place so HAVING derivation can pattern against the same registry without conflating predicates with ordering primitives). +5. **Slice `adr-close-out`** — Draft the new ADR ("ADR NNN — Unified SQL-family operation registry") under `docs/architecture docs/adrs/`. Record the unified-registry decision and explicitly supersede the "Migration of built-in comparisons …" and "Changing the built-in comparison methods" non-goal lines in ADR 203 and ADR 206. Add a "Superseded in part by ADR NNN" header note to each. Then perform the project close-out: migrate long-lived docs (if any emerged), strip repo-wide references to `projects/unify-query-operations/**`, and delete `projects/unify-query-operations/`. Scope: `docs/architecture docs/adrs/`, project-folder deletion. Linear: TML-2354 (no sub-issue). Depends on: slice 4 (the ADR documents the as-shipped state). + +## Dependencies (external) + +None. Every change is internal: contract format, descriptor shape, and registry API are unchanged (per spec § Non-goals). No external infra, library bumps, or cross-team coordination. + +- [ ] _None._ + +## Project-DoD coverage map + +Project-DoD conditions are derived from the spec's `Acceptance Criteria` (AC1–AC13). + +| Project-DoD | Delivered by | +| --- | --- | +| **AC1.** Legacy surfaces (`COMPARISON_METHODS_META`, `BuiltinFunctions`, `createBuiltinFunctions`) gone from production code. | Slice 3 | +| **AC2.** Family registers via the standard `queryOperations()` contributor surface; no separate code path. | Slice 2 | +| **AC3.** Trait gating symmetric: `fns.eq(cipherstashColumn, …)` fails type-check; `fns.like(textCol, textCol)` typechecks. | Slice 3 | +| **AC4.** Per-column ORM method surface unchanged (modulo the cipherstash tightening from AC3). | Slice 3 | +| **AC5.** `fns.` calls valid before remain valid after (cipherstash tightening aside). | Slice 3 | +| **AC6.** `isNull`/`isNotNull` reachable on every codec via `self: { any: true }`; registration validator accepts the new arm and rejects ambiguous combinations. | Slice 1 (validator) + Slice 2 (factory entries) + Slice 3 (accessor reads) | +| **AC7.** No backward-compat shims; demo/examples updated in the same change as the deletion; `pnpm lint:deps` passes. | Slice 3 (deletions) + Slice 5 (close-out audit) | +| **AC8.** HAVING surface derived, not hand-listed; `HavingComparisonMethods` deleted; numeric aggregate surface widens to include `in | notIn | isNull | isNotNull` and excludes `like`. | Slice 4 | +| **AC9.** End-to-end ORM query still builds and emits byte-identical SQL. | Slice 3 (integration tests pass unmodified) | +| **AC10.** New ADR drafted, supersedes ADR 203 / ADR 206 carve-outs. | Slice 5 | +| **AC11.** Family contract emission picks up family operation types into `QueryOperationTypes`; `asc`/`desc` not present there. | Slice 2 (emitter wiring) + Slice 3 (orderBy registry stays private) | +| **AC12.** Binary operator signatures gate by trait and tie operands (codec-id generic constrained to the relevant trait's codec-id union). | Slice 2 (factory authoring) + Slice 3 (consumers expose the new shapes) | +| **AC13.** `orderBy` / WHERE accessor split — `m.intField.asc()` works inside `orderBy`; `m.intField.asc` is absent in `where`; `fns.asc` does not exist. | Slice 3 | + +Every AC has at least one delivering slice. Slices 1, 2, and 4 each have a unique AC anchor; slice 3 carries the lion's share because it's where the user-visible unification actually happens; slice 5 is the documentation close-out. + +## Delivery model + +**Single PR for the whole project** (decision 2026-05-21). Originally each slice was to land its own PR referencing TML-2354. The operator chose to defer PR opening until all five slices are review-clean on the branch — one large PR for the whole project rather than five small ones. Trade-off: review burden moves to the end (the PR will be the sum of all slices' diffs), but the branch state during execution stays linear and the slices don't have to be reviewable independently. The slice-PR sizing guidance ("reviewable in one sitting") no longer applies per slice; it applies to the cumulative branch at project close. + +**Implication for downstream slices:** slices 2-5's plans should drop their own "open PR" close-out steps. The PR-open happens once, at project close, via `create-pr` against the cumulative branch. + +## Risks + open questions + +1. **Slice 3 size.** This slice deletes two legacy types, collapses two consumers, splits the orderBy accessor, and tightens cipherstash trait gating — all in one atomic change. The split is necessary because FR11 forbids backward-compat shims, and any intermediate state would either leak both surfaces simultaneously (asymmetric trait gating still in flight) or require a feature flag (forbidden). If review feedback judges the slice too large, the contingency is to split the orderBy-accessor work into a follow-up slice 3b, accepting a transient "WHERE accessor still has `.asc/.desc` leak" state until 3b lands. Resolve at slice-pickup time via `drive-plan-slice`. +2. **Family-as-contributor wiring.** The current `createExecutionContext` contributors list is `[stack.target, stack.adapter, ...stack.extensionPacks]` — the family descriptor is not in it. Slice 2 must extend this to also pull `queryOperations()` from the family pack. The exact wiring (add `stack.family`? lift `SqlStaticContributions` onto `RuntimeFamilyDescriptor`? pass family separately to `createExecutionContext`?) is an implementer-degree-of-freedom call but affects ergonomics for every downstream caller of `createExecutionContext`. Worth a brief design check at slice-pickup time. +3. **`Functions` type-check time (NFR2).** Removing the `BuiltinFunctions &` intersection in favour of a single derived map could regress type-check time on the demo. The hot path is the `fns.eq(...)` resolution through `DeriveExtFunctions`. Mitigation: run `pnpm typecheck` before/after on the demo + an integration target; if regression > a few percent, investigate shared `infer` slots or distributive conditionals before shipping. +4. **HAVING widening (slice 4).** The HAVING surface on numeric aggregates picks up `in | notIn | isNull | isNotNull` that weren't there before. The spec calls this "deliberate, documented widening" — but downstream users of the demo / examples may have type-tests that assert the narrower set. Slice 4 must touch any such tests, not just leave them failing. +5. **Single tracking issue, no sub-issues.** All five slices land against TML-2354. Each slice PR must reference TML-2354 (in title or body) so the GitHub-Linear integration links the merge to the issue. The issue does not auto-close on the first merged PR — close manually only after slice 5 (close-out) lands, or rely on the integration's terminal-state transition rules for the team. Confirm the team's convention at execution-start. + +## Close-out (required) + +- [ ] Verify all acceptance criteria in `projects/unify-query-operations/spec.md` (AC1–AC13) +- [ ] Mandatory final retro complete; output landed in canonical / project-context / ADR +- [ ] Migrate long-lived docs into `docs/` (none expected beyond the new ADR — confirm at close-out) +- [ ] Strip repo-wide references to `projects/unify-query-operations/**` (replace with `docs/architecture docs/adrs/ADR NNN ...` links or remove) +- [ ] Delete `projects/unify-query-operations/` +- [ ] TML-2354 closed (via PR merge auto-transition or manual close after slice 5 lands) diff --git a/projects/unify-query-operations/slices/collapse-consumers/manual-qa-run-2026-05-28.md b/projects/unify-query-operations/slices/collapse-consumers/manual-qa-run-2026-05-28.md new file mode 100644 index 0000000000..7d35e8bd60 --- /dev/null +++ b/projects/unify-query-operations/slices/collapse-consumers/manual-qa-run-2026-05-28.md @@ -0,0 +1,164 @@ +# Manual QA report — TML-2354 (collapse-consumers slice) — 2026-05-28 + +> **Script:** `projects/unify-query-operations/slices/collapse-consumers/manual-qa.md` (commit `223e67f22` at run time) +> **Runner:** Claude Opus 4.7 — autonomous LLM agent, same session as the script-author and slice implementer (author-bias caveat below) +> **Environment:** macOS / NixOS-toolchain dev worktree at `/Users/sevinf/projects/worktrees/prisma-next/mellow-juniper/prisma-next`; branch `unify-op-registries` at HEAD `223e67f22` (descended from `ccf8ec3a3`); Node version per workspace `package.json` engines; dist baseline refreshed for `@prisma-next/family-sql`, `@prisma-next/sql-builder`, `@prisma-next/sql-orm-client`, `@prisma-next/adapter-sqlite`, `@prisma-next/contract-authoring`, `@prisma-next/extension-sqlite` across D2 R2 commits. +> **Started:** 2026-05-28T16:30Z (heartbeat-recorded) +> **Finished:** 2026-05-28T17:00Z +> **Verdict:** 🔍 Triage required + +## Summary + +3 findings, all 📝 follow-up severity, awaiting orchestrator disposition confirmation. No 🛑 Blocker findings; the slice's SDoD4 criterion ("no unresolved 🛑 Blocker findings") is met. The cipherstash trait-tightening gate fires correctly (Scenario 1), the fns.ne→fns.neq rename is semantically equivalent end-to-end (Scenario 2), and the transient `LEGACY_ORDERING_METHODS` preservation works at both runtime and type level (Scenario 3). The exploratory probe surfaced one further diagnostic-copy observation that reinforces the Scenario 1 finding's pattern. **Author-bias caveat: the runner is the same LLM agent as the script author and slice implementer.** Per drive-qa-run, fresh-eyes approximation requires a different agent invocation; this dispatch did not arrange that, so the findings here should be re-validated by a different runner before the slice closes. Flagged in Suggested follow-ups. + +## Findings + +### F-1 — 📝 Follow-up — Cipherstash typecheck diagnostic does not name the failing trait `'equality'` + +**Scenario:** 1 — Cipherstash typecheck error reads well + +**Step:** 4 (capture the TypeScript error after removing `@ts-expect-error`) + +**Oracle (per script):** the diagnostic must (a) name `'equality'` (or `EqualityCodecId`) explicitly, (b) name `'cipherstash/string@1'` explicitly, (c) require ≤2 levels of "Type X not assignable to Type Y" envelopes. + +**Observed:** +``` +test/cipherstash-trait-tightening.test-d.ts(94,10): error TS2345: Argument of type 'CodecExpression<"cipherstash/string@1", false, TestCodecTypes>' is not assignable to parameter of type 'CodecExpression<"pg/int4@1", boolean, TestCodecTypes>'. + Type 'string' is not assignable to type 'CodecExpression<"pg/int4@1", boolean, TestCodecTypes>'. +``` + +**Expected (per script):** The error chain mentions `'equality'` somewhere — typically as a constraint failure on `EqualityCodecId` or on `CodecExpression` where `CodecId` resolved to `never`. + +**What actually happened:** the diagnostic fires (✓ part (a) of the gate), and names `'cipherstash/string@1'` clearly (✓ oracle point (b)), and stays within 2 levels (✓ oracle point (c)). But the trait name `'equality'` does **not** appear anywhere in the message — neither as `EqualityCodecId` nor as a literal `'equality'` string. The TypeScript inference resolved `CodecId` to `'pg/int4@1'` (the only `EqualityCodecId` candidate in the synthetic codec-types fixture) and reported "expected `pg/int4@1`, got `cipherstash/string@1`" — technically correct but tells the extension-author nothing about *why* `pg/int4@1` was what TypeScript settled on. The trait-constraint mechanic is invisible from the diagnostic. + +The positive control on `fns.eq(intCol, intCol)` (oracle implicit requirement that the gate fire selectively) holds: no diagnostic on the `intCol` line. + +**Reproduction:** +- `git rev-parse HEAD` → `223e67f22eaba53f324cfc8aaf3faab296a62edb` +- `git status` at failure capture → clean (worktree-isolated; restored after) +- Mutated file: `packages/2-sql/4-lanes/sql-builder/test/cipherstash-trait-tightening.test-d.ts` — removed line 94 `@ts-expect-error` annotation; restored via `git checkout --` after capture. +- Exact command: `pnpm --filter @prisma-next/sql-builder typecheck` + +**Notes:** the script's "Failure modes" section explicitly enumerated this category ("The cipherstash diagnostic does not mention `'equality'` anywhere — the user can't tell which trait their codec lacks"); the script-author anticipated this risk, and the runner observed the failure mode firing. Severity 📝 because the gate itself works (the regression-prevention story holds) and the user can fix their code (the error is technically correct). The improvement is in diagnostic *copy*, not behaviour. The fix path — making the trait name visible in the diagnostic — touches the family-SQL impl's generic constraint shape (`>(...)`) which is slice-2 sealed territory; routing as a 🎫 ticket for a future diagnostics-improvement pass is the conservative disposition. + +### F-2 — 📝 Follow-up — Script line-number citations drift from current file shape + +**Scenario:** 2 (step 2) and 3 (step 3) + +**Step:** the oracle's line citations in two scenarios + +**Oracle (per script):** the family-SQL `neq` impl is at lines 131–138 of `packages/2-sql/9-family/src/core/query-operations.ts` (Scenario 2); `ScalarModelAccessor` intersects `LegacyOrderingMethods` at lines 316–322 of `packages/3-extensions/sql-orm-client/src/types.ts` (Scenario 3). + +**Observed:** +``` +$ grep -n "neq:" packages/2-sql/9-family/src/core/query-operations.ts +137: neq: { +$ grep -n "type ScalarModelAccessor\b" packages/3-extensions/sql-orm-client/src/types.ts +290:type ScalarModelAccessor, ModelName extends string> = { +``` + +The family-SQL `neq` impl is actually at lines 137–144 (the script's `sed -n '131,138p'` command in step 2 dumps the `eq` impl instead). `ScalarModelAccessor`'s intersection block is at lines 290–297 (the script's `sed -n '316,325p'` lands on the unrelated `Simplify` + `VariantRow` types). + +**Expected (per script):** the line ranges named in the oracle and step commands resolve to the constructs the oracle describes. + +**Reproduction:** +- `git rev-parse HEAD` → `223e67f22eaba53f324cfc8aaf3faab296a62edb` +- `git status` at failure capture → clean (read-only scenario; no mutation) +- Exact commands: `sed -n '131,138p' packages/2-sql/9-family/src/core/query-operations.ts` (shows `eq` not `neq`); `sed -n '316,325p' packages/3-extensions/sql-orm-client/src/types.ts` (shows wrong region). + +**Notes:** script-quality drift. The symbol names (`neq:`, `type ScalarModelAccessor`) are stable and any reader using `grep` would find the right blocks immediately, so the drift is annoying-but-not-blocking. The line numbers shifted between when the script author wrote the body (recalling approximate locations from D2 R2 work) and the current file shape. Fix is a trivial markdown edit: change "lines 131–138" → "lines 137–144" in Scenario 2's oracle and `sed` command, and "lines 316–322" → "lines 290–297" in Scenario 3's step 3. + +### F-3 — 📝 Follow-up — Integration-tests `pretest` triggers Turbo cyclic-dep + prevents direct `pnpm run test` + +**Scenario:** 2 (step 4) + +**Step:** running `pnpm --filter '@prisma-next/integration-tests' run test -- test/sql-builder/where.test.ts` + +**Oracle (per script):** the integration test "neq(col, null) produces IS NOT NULL" passes; the test-suite output for that test reads `✓ test/sql-builder/where.test.ts > integration: WHERE > neq(col, null) produces IS NOT NULL`. + +**Observed:** +``` +> @prisma-next/integration-tests@0.11.0 pretest … +> pnpm -w build +… + WARNING Circular package dependency detected: @prisma-next/family-sql, @prisma-next/sql-runtime, … + x Cyclic dependency detected: + | @prisma-next/sql-runtime#build, @prisma-next/cli#build, @prisma-next/ + | target-postgres#build, @prisma-next/adapter-postgres#build, @prisma-next/ + | sql-builder#build, @prisma-next/family-sql#build + ELIFECYCLE Command failed with exit code 1. +``` + +The `pretest` hook (`pnpm -w build`) trips Turbo's cyclic-dep detection and exits non-zero before `vitest` runs. + +**Workaround (used to complete the scenario):** run `vitest` directly inside the integration package, bypassing the `pretest` hook: +``` +$ cd test/integration && pnpm vitest run test/sql-builder/where.test.ts + ✓ test/sql-builder/where.test.ts (7 tests) 1194ms + Test Files 1 passed (1) + Tests 7 passed (7) +``` + +After the workaround, the integration test passes (7/7 including the `neq(col, null) produces IS NOT NULL` test). + +**Expected (per script):** the `pnpm --filter '@prisma-next/integration-tests' run test -- …` command-line invocation completes and surfaces the test result inline. + +**Reproduction:** +- `git rev-parse HEAD` → `223e67f22eaba53f324cfc8aaf3faab296a62edb` +- `git status` at failure → clean (no mutation) +- Exact command: `pnpm --filter '@prisma-next/integration-tests' run test -- test/sql-builder/where.test.ts` + +**Notes:** pre-existing infrastructure. This Turbo cyclic-dep warning has surfaced multiple times across the D2 R2 session (R1 first observed it during `pnpm build`; D2 R2 fixture-regen rounds hit it again). It's not specific to the QA run, but the script's step-4 command should be amended either to use the `vitest` direct invocation or to add a `--ignore-scripts` / equivalent bypass of the `pretest` hook. The fix is a small script edit (one-line command change in Scenario 2 step 4); the underlying Turbo configuration cycle is its own follow-up well outside this slice's scope. + +## Per-scenario log + +| # | Scenario | Isolation | Wallclock | Result | Findings | +| - | -------- | --------- | --------- | ------ | -------- | +| 1 | Cipherstash typecheck error reads well | workspace | ~3 min (incl. plant + capture + restore) | ✅ pass-with-follow-up | F-1 | +| 2 | Demo's renamed `fns.neq` byte-identical | read-only | ~2 min (incl. workaround) | ✅ pass-with-follow-ups | F-2 (line drift), F-3 (pretest cycle) | +| 3 | `m.field.asc()` via `LEGACY_ORDERING_METHODS` | read-only | ~1.5 min | ✅ pass-with-follow-up | F-2 (line drift) | +| 4 | Exploratory: chained-result + diagnostic probe | workspace | ~15 min (under-budget; the charter named 30 min and the runner spent ~15 min before stopping with un-explored ideas captured under "Exploratory notes") | (notes; see below) | reinforces F-1 pattern | + +*(Note: "pass-with-follow-up" in the Result column is per-scenario shorthand, not the run verdict. The run verdict is 🔍 Triage required per the disposition map; see § Coverage outcome.)* + +## Exploratory notes (Scenario 4) + +Probed three things from the charter, in ~15 of the budgeted 30 minutes: + +**(c) Chained-method-on-chained-result diagnostic** — planted a typo: `postAccessor.embedding.cosineDistance([1, 2, 3]).liek('%foo%')` (typo on `like` → `liek`) inside `extension-operations.test-d.ts`. Captured the diagnostic: + +``` +test/sql-orm-client/extension-operations.test-d.ts(185,54): error TS2339: Property 'liek' does not exist on type '{ eq: (b: CodecExpression, boolean, CodecTypes>) => AnyExpression; ... 8 more ...; isNotNull: () => AnyExpression; } & { ...; }'. +``` + +Observations: the diagnostic names `liek` clearly; the available-method list previews with `eq: ...` and `8 more ...` and `isNotNull: ...` plus an `& { ...; }` tail; TypeScript does NOT suggest `like` as a "Did you mean" candidate (because `like` legitimately isn't on the chained result — numeric-trait codec lacks `textual`). The diagnostic exposes `EqualityCodecId` as a visible framework-internal name inside the type printout — same family of issue as F-1. The chained-result surface's "what's available" is at least partly self-documenting via the type printer's truncated preview, which is better than the cipherstash case. + +**(a) Malformed `self.traits` diagnostic** — un-explored. Would require constructing a contrived extension-pack contribution to surface the diagnostic; out of time budget. Logging as a candidate scenario for the next QA round: "What happens if an extension author writes `self: { trits: ['equality'] }` (typo on `traits`) — does the resulting diagnostic name the typo'd slot clearly?" + +**(b) Editor-hover legibility on column accessors** — un-explored. The runner is a CLI agent without an editor-language-service shell; evaluating "what does the hover look like in VS Code" requires a different runner shape (a developer with an editor in front of them). Logging as a candidate scenario for a developer-led future round, possibly hand-shaped per drive-qa-run's note that LLM runners and human runners cover different ground. + +## Coverage outcome + +| AC ID | Scenario(s) | Result | Notes | +| ----- | ----------- | ------ | ----- | +| AC3 | 1 | ✅ pass-with-follow-up | F-1 (diagnostic doesn't name `'equality'`); gate fires correctly | +| AC4 | (CI; not manual-QA scope) | N/A | — | +| AC9 | 2 | ✅ pass-with-follow-ups | F-2 (line drift), F-3 (pretest cycle); semantic equivalence confirmed end-to-end | +| AC13 | 3 (partial) | ✅ pass-with-follow-up | F-2 (line drift); transient preservation works at runtime + type level | +| Other ACs | (CI; not manual-QA scope) | N/A | — | + +## Disposition map + +| Finding | Severity | Proposed disposition | Evidence / next step | +| ------- | -------- | -------------------- | -------------------- | +| F-1 | 📝 Follow-up | 🎫 ticket | The diagnostic improvement requires touching family-SQL's `>(...)` generic constraint shape, which is slice-2 sealed territory; the slice-2 carve-out for the `CodecIdsWithTrait` fix in D2 R2 was specifically scoped to the bug, not to widening the diagnostic copy. File against a future diagnostics-improvement initiative (or as a sub-issue of TML-2354's follow-ups). Orchestrator to file the ticket and record the ID here. | +| F-2 | 📝 Follow-up | 🎫 ticket | One-line markdown edit to `manual-qa.md`'s Scenario 2 step 2 oracle (lines 131–138 → 137–144) and Scenario 3 step 3 (lines 316–322 → 290–297). Out of this dispatch's 2-commit scope per the brief; track as a markdown-cleanup follow-up. Orchestrator to file the ticket and record the ID. | +| F-3 | 📝 Follow-up | 🎫 ticket | Script step-4 command needs amending to bypass the `pretest` cycle (either `vitest` direct invocation or a `--ignore-scripts`-style flag). The underlying Turbo cyclic-dep is a pre-existing infrastructure issue surfaced repeatedly across D2 R2 and earlier; the script fix is one-line, the infrastructure fix is its own ticket. Orchestrator to file both and record the IDs. | + +## Suggested follow-ups + +- **F-1 (🎫 ticket):** improve the cipherstash trait-tightening diagnostic so it names the `'equality'` trait constraint visibly in the error message. Touches slice-2-sealed territory; needs to wait for a sanctioned diagnostics-improvement pass or be folded into the next consumer-facing slice (3b, when the ORM ordering registry lands and naturally re-shapes the relevant constraints). +- **F-2 (🎫 ticket):** one-line markdown edits to `manual-qa.md` to refresh the line citations in Scenarios 2 and 3. Trivial; can be batched with any future script-quality cleanup. +- **F-3 (🎫 ticket):** amend `manual-qa.md` Scenario 2 step 4 command to use `cd test/integration && pnpm vitest run …` (or document the `pretest` bypass another way) so the script's invocation works as-written. Pair with a separate infrastructure ticket for the Turbo cyclic-dep root cause. +- **Run again with a different runner / fresh eyes.** Per drive-qa-run's author-bias note, the runner here is the same LLM agent as the script-author and slice implementer; the slice's SDoD4 says "≥1 run report" (this one satisfies the cardinality), but a confirmatory pass by a different agent (or a developer with an editor in front of them — useful for the un-explored exploratory probe (b)) would meaningfully reduce author-bias risk before the slice's PR opens. +- **Scenario 4's un-explored probes** as candidate scenarios for the next QA round: (a) malformed `self.traits` diagnostic shape; (b) editor-hover legibility on representative column-accessor types (developer-driven, not LLM-driven). diff --git a/projects/unify-query-operations/slices/collapse-consumers/manual-qa.md b/projects/unify-query-operations/slices/collapse-consumers/manual-qa.md new file mode 100644 index 0000000000..fa519205b5 --- /dev/null +++ b/projects/unify-query-operations/slices/collapse-consumers/manual-qa.md @@ -0,0 +1,215 @@ +# Manual QA — TML-2354 (collapse-consumers slice) + +> **Be the extension-author.** You are an outside developer building (or maintaining) an extension on top of `@prisma-next/*`. You don't read framework internals before reaching for the SQL builder or the ORM column accessor — you read the diagnostics they emit, the demo's call sites, and the shapes the column-accessor type surfaces in your editor. This script walks you through the four user-visible promises the slice ships and asks you to judge whether the promises *read* the way they should from the outside. +> +> **Out of scope of this script.** Re-running CI's `pnpm test:packages` / `pnpm typecheck` / `pnpm lint:deps` against today's clean tree. Those passed on the implementer's machine and are CI's responsibility; re-running them locally only proves your machine matches CI. The user-meaningful versions of those checks live as the negative-control scenario (Scenario 1) and the integration probes (Scenarios 3 and 4). +> +> **Spec:** `projects/unify-query-operations/slices/collapse-consumers/spec.md` +> **Plan:** `projects/unify-query-operations/slices/collapse-consumers/plan.md` +> **Project spec:** `projects/unify-query-operations/spec.md` (AC3, AC4, AC9, AC13 are the slice-relevant ACs) +> **PR:** none yet — single PR at project close per the amended delivery model. + +## Table of contents + +| # | Scenario | What it proves | Isolation | Covers | +| - | -------- | -------------- | --------- | ------ | +| 1 | Cipherstash typecheck error reads well (negative control + positive control) | The trait-tightening gate fires on `fns.eq(cipherstashCol, cipherstashCol)` with a diagnostic that names the failing constraint clearly, and the symmetric `fns.eq(intCol, intCol)` typechecks unchanged | workspace | AC3 | +| 2 | Demo's renamed `fns.neq` call sites lower to byte-identical SQL by construction | The `fns.ne → fns.neq` rename across the demo's `cross-author-similarity` query is a name-only change; the underlying registry op + the integration test for `fns.neq` confirm runtime SQL equivalence | read-only | AC9 | +| 3 | `m.field.asc()` still works inside `orderBy` callbacks via `LEGACY_ORDERING_METHODS` | The transient asc/desc surface preserves the orderBy callback's `.asc()` / `.desc()` surface on column accessors; runtime + type-level tests both green | read-only | AC13 (partial — slice 3b's territory completes the split) | +| 4 | Extension-author exploratory: probe the chained-result surface and diagnostic copy | Surfaces unknown unknowns in the post-R2 column accessor + chained-result diagnostics from an extension-author lens | workspace | (charter; no specific AC) | + +> Scenario 1 is a **(negative control)** — plants a violation (removing the `@ts-expect-error` annotation) and observes the gate fire. Scenarios 1 and 4 are **(judgement)** — runner evaluation of diagnostic quality against an explicit oracle. Scenario 4 is **(exploratory)** — time-boxed charter, no scripted steps. +> +> The **Isolation** column tells the runner how to schedule the scenario in parallel: `tmpdir` (own scratch dir, shared read-only clone), `workspace` (own `git worktree`), `read-only` (no isolation needed), or `external` (network-bound; rate-limit-aware). + +## Pre-flight + +1. Confirm `git rev-parse HEAD` resolves to `ccf8ec3a3` or later (the final D2 R2 commit). If you're on a newer commit, confirm it's a descendant of `ccf8ec3a3` and that no commit after it touched `packages/2-sql/4-lanes/sql-builder/`, `packages/2-sql/9-family/`, or `packages/3-extensions/sql-orm-client/src/`. +2. Confirm `git status` is clean. If it isn't, stash or commit first — the script's negative-control scenario mutates a tracked file inside a worktree, so the user's checkout must be clean at the start so the report's `git status` evidence is unambiguous. +3. Confirm the package dist baseline is current. This session has documented three partial-dist gotchas (R1 `contract-authoring`, R2 `extension-sqlite`, R2 `adapter-sqlite`); avoid a fourth here. Run `pnpm build` (or, if that's too heavy, at minimum `pnpm --filter @prisma-next/family-sql --filter @prisma-next/sql-builder --filter @prisma-next/sql-orm-client --filter @prisma-next/adapter-postgres --filter @prisma-next/adapter-sqlite --filter @prisma-next/extension-sqlite --filter @prisma-next/contract-authoring build`). Confirm `pnpm fixtures:check` exits 0 after build — that's the canary that downstream consumers can resolve the dists. +4. Confirm node version satisfies the root `package.json`'s `engines.node` constraint (do not switch via `nvm`/`fnm`; report a misconfigured shell as a finding instead). +5. Allocate per-scenario isolation contexts per `drive-qa-run`'s § 3a — shared read-only clone for `read-only` / `tmpdir` scenarios, fresh `git worktree --detach` per `workspace` scenario. + +## Scenario 1 — Cipherstash typecheck error reads well (negative + positive control) + +**What you're proving from the user's seat:** an extension-author building on top of `@prisma-next/sql-builder` types out `fns.eq(myCipherstashColumn, myCipherstashColumn)` in their editor. They expect the TypeScript error message to (a) fire at all, (b) name the constraint that failed (the missing framework `equality` trait), and (c) point them at the codec id of the failing argument so they know which of their fields tripped the gate. CI proves (a) — the `@ts-expect-error` annotation on `cipherstash-trait-tightening.test-d.ts` keeps the suite green. The user-meaningful versions are (b) and (c), which require *reading the actual diagnostic*. This scenario is the negative-control half (plant the violation by removing the annotation, observe the diagnostic) plus the positive control (`fns.eq(intCol, intCol)` — the symmetric trait-bearing case that must still typecheck). + +**Covers:** AC3. + +**Isolation:** `workspace` — the scenario edits a tracked source file (`cipherstash-trait-tightening.test-d.ts`) inside a worktree so the runner can read TypeScript's actual diagnostic message without modifying the user's live checkout. + +**Oracle:** the diagnostic must: +- Name `'equality'` (or `EqualityCodecId`) explicitly somewhere in the message — it's the trait the cipherstash codec lacks, and the user's mental model of "why did this fail" depends on seeing the trait name. +- Name `'cipherstash/string@1'` (or its equivalent `CodecId` resolution to a non-includable value, typically `never`) — the user needs to know which codec id was rejected. +- Not require the user to recursively chase 3+ `Type '' is not assignable to type ''` envelopes to find (a) and (b). Two levels of `Type … is not assignable to …` framing is acceptable; deeper is a finding. + +Coverage boundary: this scenario probes the cipherstash-codec-shaped negative case (the codec advertises only `cipherstash:equality`, not the framework-canonical `equality`). It does NOT prove the diagnostic reads well for every possible trait mismatch — only the one we constructed. A trait the user invented in their own extension (e.g. `myorg/equality`) would surface a structurally similar message; whether it does so usefully is outside this scenario's scope. + +**Preconditions:** +- Workspace worktree is fresh from `HEAD` (`git worktree add --detach $PN_QA_WORKTREES/scenario-1 HEAD`). +- `pnpm install` is up to date in the worktree (the root install at pre-flight covers this when the worktree shares the workspace `node_modules`; if it doesn't, run `pnpm install --frozen-lockfile` inside the worktree). +- `pnpm --filter @prisma-next/sql-builder build` has been run in the worktree at least once (the test-d file's imports resolve through `dist/`; a stale dist would shift the diagnostic in ways unrelated to the slice). + +### Steps + +1. Open `packages/2-sql/4-lanes/sql-builder/test/cipherstash-trait-tightening.test-d.ts` in the worktree. +2. Locate the `@ts-expect-error cipherstash codec lacks the framework 'equality' trait` annotation (~line 89). +3. Delete the annotation line. Save. +4. From the worktree root: `pnpm --filter @prisma-next/sql-builder typecheck`. +5. Capture the full TypeScript error block emitted for the modified test (the lines starting with `test/cipherstash-trait-tightening.test-d.ts(,): error TS…`). +6. Without restoring the file, also confirm the *other* `fns.eq` call earlier in the file (`fns.eq(intCol, intCol)`) does **not** produce a diagnostic — verifies the gate fires selectively (positive control). +7. Restore the file (see § Restore). + +### What you should see + +- A single TypeScript error message anchored at the `fns.eq(cipherstashCol, cipherstashCol)` line. +- The error chain mentions `'equality'` somewhere — typically as a constraint failure on `EqualityCodecId` or on `CodecExpression` where `CodecId` resolved to `never`. +- The error chain mentions `'cipherstash/string@1'` somewhere — either as the source type that was rejected, or as the codec id that didn't bind. +- The `fns.eq(intCol, intCol)` call earlier in the file does NOT produce a diagnostic on the same `pnpm typecheck` run. (Note: the `@ts-expect-error` you removed was on the cipherstash line only; the int4 line never had one.) +- The error envelope's depth is judgement-territory — call out if you have to chase more than two layers of "Type X not assignable to Type Y" to recover the trait name and codec id. + +### Failure modes (anything matching these is a finding the runner classifies) + +- No diagnostic fires on the cipherstash line after the annotation is removed (the gate doesn't gate — original-bug regression class). +- The cipherstash diagnostic does not mention `'equality'` anywhere — the user can't tell which trait their codec lacks. +- The cipherstash diagnostic does not mention `'cipherstash/string@1'` anywhere — the user can't tell which of their codecs tripped the gate. +- The `fns.eq(intCol, intCol)` line ALSO produces a diagnostic — the positive control fails, meaning the gate is over-firing. +- The diagnostic mentions an internal symbol name (`CodecIdsWithTrait`, `OpMatchesField`, an internal `infer` variable name) at the top of the error chain instead of as deep context — surfaces framework internals before the user-meaningful constraint. +- The error chain requires >2 layers of `Type '…' is not assignable to type '…'` to reach the trait name and codec id (depth-of-diagnostic judgement). + +### Restore (mutates a tracked file) + +1. Discard the edit: `git -C $PN_QA_WORKTREES/scenario-1 checkout -- packages/2-sql/4-lanes/sql-builder/test/cipherstash-trait-tightening.test-d.ts`. +2. Confirm clean: `git -C $PN_QA_WORKTREES/scenario-1 status` returns no working-tree changes. +3. Worktree itself is torn down at end-of-run per `drive-qa-run`'s § 3b. + +## Scenario 2 — Demo's renamed `fns.neq` call sites lower to byte-identical SQL by construction + +**What you're proving from the user's seat:** an extension-author who follows the demo to learn the SQL-builder DSL needs to know that the recent `fns.ne → fns.neq` rename (the only user-visible code change in D1 of this slice) does not change the SQL the demo emits. Without that confirmation, anyone copying the demo's query patterns into their own extension could quietly emit a different operator and chase a runtime difference they didn't expect. CI's `where.test.ts` integration test covers `fns.neq` end-to-end (executes against a real driver, asserts row counts) — this scenario adds the *judgement* layer over that: read the demo's three rename sites, read the family-SQL registry's `neq` impl, confirm by construction the SQL is identical, and run the integration test as the executable sanity check. + +**Covers:** AC9. + +**Isolation:** `read-only` — the scenario reads source files and runs an integration test that doesn't mutate the working tree. + +**Oracle:** semantic equivalence by construction, verified by execution: +- The family-SQL registry's `neq` implementation at `packages/2-sql/9-family/src/core/query-operations.ts` (lines 131–138) is `BinaryExpr('neq', ...)` plus the `null`-coalescing branch. The rename from `fns.ne` to `fns.neq` is a *name change at the call-site proxy*; the resolved registry impl is identical. SQL lowering of `BinaryExpr('neq', ...)` produces `<>` regardless of what the call-site proxy was named — that's the by-construction equivalence claim. +- The integration test `test/integration/test/sql-builder/where.test.ts:65-75` ("neq(col, null) produces IS NOT NULL") executes `fns.neq(f.invited_by_id, null)` against PGlite and asserts the row count. A green run confirms the runtime SQL is correct end-to-end. +- The demo's three rename sites at `examples/prisma-next-demo/src/queries/cross-author-similarity.ts:44, 54` all use the same `fns.neq(, )` call shape exercised by the integration test. + +The composition of these three reads + the test run is the byte-identical proof: same registry op resolves at runtime regardless of the source-code spelling, same SQL emitted, demonstrated end-to-end on a real driver. + +**Preconditions:** +- Read-only access to the shared workspace clone is sufficient. +- The integration test setup (`setupIntegrationTest()` in `test/integration/test/sql-builder/setup.ts`) bootstraps PGlite in-memory; no external DB needed. + +### Steps + +1. Read the three rename sites in the demo: `grep -n "fns\.neq" examples/prisma-next-demo/src/queries/cross-author-similarity.ts`. Confirm three hits at the line numbers noted in the oracle. +2. Read the family-SQL `neq` impl: `sed -n '131,138p' packages/2-sql/9-family/src/core/query-operations.ts`. Confirm the impl is `BinaryExpr('neq', ...)` (modulo the null-branch). +3. Confirm no `fns.ne` references remain in production code: `rg 'fns\.ne\b' packages/ examples/ test/ -g '!*.test*'`. Expected output: zero hits. +4. Run the integration test that exercises `fns.neq` end-to-end: `pnpm --filter '@prisma-next/integration-tests' run test -- test/sql-builder/where.test.ts`. Capture the test-suite output for the lines mentioning `neq`. + +### What you should see + +- Three `fns.neq` call sites in `cross-author-similarity.ts` at lines 44, 54 (two on line 54 in a single `fns.and(...)` composition). +- The family-SQL `neq` impl at lines 131–138 reads `impl: (a, b) => { if (b === null) return boolExpr(NullCheckExpr.isNotNull(toExpr(a))); ... return boolExpr(binaryWithSharedCodec(a as ExprOrVal, b as ExprOrVal, 'neq')); }` — i.e. produces a `BinaryExpr` with op `'neq'` (or a `NullCheckExpr` for the null-coalescing case). The `'neq'` literal is what lowers to SQL `<>`. +- The `rg 'fns\.ne\b'` for production code returns zero hits — the rename is complete. +- The integration test "neq(col, null) produces IS NOT NULL" passes; the test-suite output for that test reads `✓ test/sql-builder/where.test.ts > integration: WHERE > neq(col, null) produces IS NOT NULL` (or equivalent). + +### Failure modes (anything matching these is a finding the runner classifies) + +- Any `fns.ne` reference remains in production code (the rename is incomplete) — surfaces an inconsistency that contradicts D1's commit message and slice spec SDoD11. +- The integration test "neq(col, null) produces IS NOT NULL" fails on the current branch — the runtime SQL for `fns.neq` is broken. +- The family-SQL `neq` impl emits something other than `BinaryExpr('neq', ...)` (e.g. the lowering accidentally fires through a renamed `'ne'` op-code, producing different SQL). + +(No restore — read-only scenario.) + +## Scenario 3 — `m.field.asc()` still works inside `orderBy` callbacks via `LEGACY_ORDERING_METHODS` + +**What you're proving from the user's seat:** an extension-author using the ORM collection surface writes `.orderBy(m => m.id.asc())`. The slice deletes `COMPARISON_METHODS_META` (which previously carried `asc`/`desc`) but preserves the user-visible behaviour via a transient `LEGACY_ORDERING_METHODS` map in `model-accessor.ts` that slice 3b will remove when the proper ORM ordering registry lands. This scenario confirms the transient preservation actually preserves — both at runtime (existing ORM integration tests that use `.asc()` continue to pass) and at the type level (the column-accessor type continues to expose `.asc()` / `.desc()` on `'order'`-trait codecs). + +**Covers:** AC13 (partial; slice 3b's territory completes the orderBy/WHERE accessor split per AC13 (b) — the "WHERE accessor does not expose asc/desc" half of the AC). + +**Isolation:** `read-only` — runs existing tests against the shared workspace clone. + +**Oracle:** +- `packages/3-extensions/sql-orm-client/src/model-accessor.ts` contains `LEGACY_ORDERING_METHODS` (top of file, ~line 28-55) gated on the `'order'` trait. The runtime synthesis at `attachOperationMethods` (~line 209-230) attaches `asc` / `desc` from this map to every column accessor whose codec declares the `'order'` trait. +- `packages/3-extensions/sql-orm-client/src/types.ts` contains `LegacyOrderingMethods` (~line 128-145) — the type-level mirror — gated on `'order'` extends `Traits`. `ScalarModelAccessor` intersects it (~line 318) so the column-accessor type exposes `asc()` / `desc()` when the field's codec carries `'order'`. +- Runtime tests in `packages/3-extensions/sql-orm-client/test/` and `test/integration/test/sql-orm-client/` exercise `.asc()` / `.desc()` calls; if `LEGACY_ORDERING_METHODS` were broken, those tests would fail at typecheck or runtime. + +**Preconditions:** +- Read-only access to the shared workspace clone is sufficient. +- The sql-orm-client package's `test:` script bootstraps its own fixtures; no external resources needed. + +### Steps + +1. Read `LEGACY_ORDERING_METHODS` in `packages/3-extensions/sql-orm-client/src/model-accessor.ts:28-55`. Confirm the JSDoc names "slice 3b" as the removal target and `'order'` as the gating trait. +2. Confirm the type-level mirror at `packages/3-extensions/sql-orm-client/src/types.ts:128-145`: `type LegacyOrderingMethods = 'order' extends Traits ? { asc(): OrderByItem; desc(): OrderByItem } : Record`. +3. Confirm `ScalarModelAccessor` intersects `LegacyOrderingMethods` at `packages/3-extensions/sql-orm-client/src/types.ts:316-322`. +4. Run the sql-orm-client package tests with the asc/desc-exercising suites filtered: `pnpm --filter @prisma-next/sql-orm-client test -- model-accessor query-plan-select collection.state`. These three files between them exercise `column.asc()` / `column.desc()` calls at runtime in the sql-orm-client package's own unit harness (no integration DB needed). +5. (Optional, if the integration DB harness is convenient) run `pnpm --filter '@prisma-next/integration-tests' run test -- test/sql-orm-client/extension-operations.test.ts` — that suite at lines 48 and 75 calls `.orderBy(p => p.embedding.cosineSimilarity(searchVec).desc())` / `.asc()`, exercising both the column-level and chained-result-level `LegacyOrderingMethods` surfaces. +6. (Optional, but high-judgement-value) read `packages/3-extensions/sql-orm-client/src/types.ts:128-145` again and ask: would an extension-author reading this for the first time (without context on slice 3b) understand the type is transient and why? The doc comment is the oracle for "is the transient surface signposted clearly". + +### What you should see + +- `LEGACY_ORDERING_METHODS` in `model-accessor.ts` carries a `**Removed by slice 3b**` JSDoc heading (the slice spec's named contract for the transient). +- `LegacyOrderingMethods` in `types.ts` carries a parallel JSDoc that names `./model-accessor.ts` as the runtime mirror. +- Step 4 tests pass; the suite output reads `✓ test/model-accessor.test.ts (…)` / `✓ test/query-plan-select.test.ts (…)` / `✓ test/collection.state.test.ts (…)` and no test concerning `.asc` / `.desc` fails. +- Step 5 (if run) passes; the `cosineSimilarity(...).asc()` / `.desc()` integration test rows match expectation. +- The transient annotation reads clearly to a fresh reader — the "slice 3b removes this" rationale is legible from the JSDoc alone, without needing to read the slice spec. + +### Failure modes (anything matching these is a finding the runner classifies) + +- `LEGACY_ORDERING_METHODS` (the runtime value) or `LegacyOrderingMethods` (the type-level mirror) is missing from its named file — the transient surface isn't preserved. +- The transient is not annotated as transient (no "slice 3b" or equivalent removal-target callout in the JSDoc) — future maintainers wouldn't know to delete it as a pair when slice 3b lands. +- Any `model-accessor.test.ts`, `query-plan-select.test.ts`, or `collection.state.test.ts` test concerning `.asc()` / `.desc()` fails — the runtime synthesis is broken. +- The integration test (step 5) fails on a `.desc()` / `.asc()` line — the chained-result `LegacyOrderingMethods` mirror is broken. +- The JSDoc on either half of the pair refers to the deleted runtime by a name that no longer matches (e.g. references `COMPARISON_METHODS_META.asc` without context that it's been deleted) — the breadcrumb is stale. + +(No restore — read-only scenario.) + +## Scenario 4 — Exploratory: probe the extension-author chained-result and diagnostic surface + +**Charter.** "Explore the post-R2 column-accessor + chained-result surface from an extension-author's editor for 30 minutes. Probe: (a) when an extension op's `self.traits` is malformed or absent, does the resulting diagnostic name the missing slot clearly? (b) does the column-accessor type, when hovered in an editor on a representative codec (`pg/int4@1`, `pg/text@1`, `cipherstash/string@1`), surface a comprehensible method set, or does it dump TypeScript intersection envelopes the user must mentally unfold? (c) does chaining `column.cosineDistance(v).` produce useful diagnostics for both valid (gt, lt) and invalid (like, ilike) chained methods? Surface anything that reads poorly, looks surprising, or 'felt off' but you can't yet name." + +**Covers:** (no specific AC; charters surface unknowns) + +**Isolation:** `workspace` — exploration may involve typing into a sandboxed test file to inspect editor / `tsc` output. The worktree is allocated; the user's checkout stays clean. + +**Time budget:** 30 minutes. Stop when the timer rings even if you have ideas left — log un-explored ideas as candidate scenarios for the next QA round. + +**Notes capture:** write what you tried, what surprised you, anything that 'felt off' but you can't yet name. Findings discovered here get filed in the report's Findings section the same way scripted-scenario findings do. + +### Failure modes (anything matching these is a finding the runner classifies) + +- A diagnostic on a representative extension-author misuse (e.g. calling `like` on a numeric chained result) is incomprehensible without reading framework internals. +- The column-accessor's hovered type in an editor is dominated by internal symbol names (`FieldOperations`, `ChainedResultMethods`, `OpMatchesField`) rather than user-facing method signatures. +- The chained-result surface exposes methods the slice spec says it shouldn't (e.g. `asc()` on a chained-result of a non-`order`-trait codec) — silent surface widening. +- The doc comments on `LegacyOrderingMethods`, `ChainedResultMethods`, or `LEGACY_ORDERING_METHODS` read as if they were written for the implementer (slice-internal vocabulary) rather than a future maintainer. + +(No restore inside the scenario; worktree torn down at end-of-run.) + +## Scenarios deliberately not in this script + +| AC | Why it's not a manual-QA scenario | +| -- | --------------------------------- | +| AC1 (legacy surfaces gone) | CI grep covers it (SDoD6: `rg 'COMPARISON_METHODS_META\|BuiltinFunctions\|ComparisonMethodFns\|createBuiltinFunctions' packages/ examples/ test/` returns zero production hits). Re-running it here adds nothing. | +| AC2 (family registers via standard contributor surface) | Slice 2's territory; slice 2 closed before this slice opened. The family's `queryOperations()` factory already exists; slice 3 only collapses *consumers*. | +| AC4 (per-column ORM method surface unchanged) | Verified by the existing type-d tests in `packages/3-extensions/sql-orm-client/test/` (e.g. `annotations.types.test-d.ts`'s column-accessor probes; `extension-operations.test-d.ts`'s 14-method `.toHaveProperty()` fan). D2 R1's return shape reported the per-codec key set unchanged. Re-running the type-d suite here adds nothing CI doesn't already cover. | +| AC5 (`fns` surface still callable) | CI's `pnpm --filter @prisma-next/sql-builder test` exercises every `fns.` call site. The cipherstash trait tightening from AC3 is the only intentional difference and is covered by Scenario 1. | +| AC6 (`isNull` / `isNotNull` reachable everywhere) | Existing test-d files and runtime tests in `family-sql/` and `sql-orm-client/` cover this. The slice doesn't change `isNull` / `isNotNull` semantics; they continue to declare `self: { any: true }`. | +| AC7 (no backward-compat shims) | Verified by `pnpm lint:deps` clean + the AC1 grep. CI covers both. | +| AC8 (HAVING surface is derived) | Slice 4's territory. `HavingComparisonMethods` is explicitly preserved by this slice per the slice spec SDoD5; slice 4 deletes it. | +| AC10 (new ADR) | Slice 5's territory (project close-out). | +| AC11 (family contract emission) | Slice 2's territory + D1's fixture re-emit + D2 R2's fixture re-emit sweep. The 7-commit fixture-regen across D2 R2 forms the evidence trail; re-running emit here would only re-confirm. | +| AC12 (binary operator signatures gate by trait) | The cipherstash side is covered by Scenario 1; the broader trait-gating tests (gt/lt/gte/lte against `order`, like against `textual`) are CI typecheck territory. Adding scenarios for them here would re-validate CI's work without bringing the human-judgement layer Scenario 1 brings for cipherstash. | + +## Sign-off coverage map + +| AC ID | Scenario(s) covering it | +| ----- | ----------------------- | +| AC3 | 1 | +| AC4 | (CI; not manual-QA scope) — see "Scenarios deliberately not in this script" | +| AC9 | 2 | +| AC13 | 3 (partial: the `m.field.asc()` half — the slice spec defers the full orderBy/WHERE split to slice 3b) | +| Other ACs | (CI; not manual-QA scope) — see "Scenarios deliberately not in this script" | diff --git a/projects/unify-query-operations/slices/collapse-consumers/plan.md b/projects/unify-query-operations/slices/collapse-consumers/plan.md new file mode 100644 index 0000000000..a812cc9c97 --- /dev/null +++ b/projects/unify-query-operations/slices/collapse-consumers/plan.md @@ -0,0 +1,184 @@ +# Slice plan: collapse-consumers + +**Spec.** [`./spec.md`](./spec.md). +**Parent project.** [`projects/unify-query-operations/`](../../). +**Linear.** TML-2354. Per the amended delivery model (single PR at project close), this slice does NOT open its own PR; commits land on `unify-op-registries`. +**Branch.** `unify-op-registries`. +**Base commit (head before slice 3 starts):** `0b3259192` (project plan amendment recording the 3 → 3 + 3b split). + +## Decomposition rationale + +Three dispatches. The dependency analysis surfaced a non-obvious sequencing constraint that drove the final shape: + +**Sequencing constraint.** The slice spec lists three logical workstreams (sql-builder cleanup, ORM accessor collapse, consumer migration + manual-QA). The naive ordering would be "workstream order = dispatch order." But the `fns.ne → fns.neq` rename in workstream 3 must happen BEFORE workstream 1 lands — the moment `BuiltinFunctions['ne']` is deleted, every existing `fns.ne(...)` callsite stops typechecking. The fix: pull the rename INTO workstream 1's dispatch (the sql-builder cleanup dispatch). The rename is small (11 sites across 5 files) and lives in the same conceptual scope (it's the consumer-side migration of `fns.ne` users to the new family-sourced `fns.neq`). + +The doc-comment updates split across two natural homes: the family-sql comments at `core/query-operations.ts:15+127` are *sql-builder-deletion-driven* (they reference `BuiltinFunctions` as the lowering-parity source); the cipherstash comments at `execution/operators.ts:39` + `test/equality-trait-removal.test.ts` are *COMPARISON_METHODS_META-deletion-driven*. Each goes into the dispatch that drives the deletion. + +**Final shape:** + +- **D1 (M)** — full sql-builder workstream: rename `fns.ne → fns.neq`; delete `BuiltinFunctions` + `createBuiltinFunctions`; modify `Functions` type; simplify `createFunctions` Proxy; add cipherstash AC3 typecheck test; update family-sql lowering-parity comments. +- **D2 (M)** — full ORM workstream: collapse the two-loop synthesis (`createScalarFieldAccessor` + `createExtensionMethodFactory`); preserve `asc`/`desc` in `LEGACY_ORDERING_METHODS`; delete `COMPARISON_METHODS_META` + `ComparisonMethodFns` + `MethodFactory` + `ComparisonMethodMeta` + `scalarComparisonMethod` + `listComparisonMethod`; update cipherstash doc-comments. +- **D3 (S)** — manual-QA only: author `manual-qa.md` per `drive-qa-plan`; run it per `drive-qa-run`; record findings. + +**Why D3 is its own dispatch rather than tail-end of D2.** Manual-QA is a distinct discipline from implementation — it requires a different stance (run the system, watch a real user surface), produces a different artifact (markdown script + run report), and its findings can prompt new work (re-open a dispatch if a 🛑 Blocker surfaces). Bundling it with D2 risks the implementer treating it as a tick-the-box step rather than a deliberate end-to-end verification of the user-visible cipherstash tightening. + +## Dispatches + +### Dispatch 1: sql-builder cleanup — rename, delete, simplify, verify AC3 + +**Intent.** Ship the entire sql-builder side of slice 3 in one dispatch. (1) Rename `fns.ne` to `fns.neq` across the 11 sites in 5 files identified in the slice spec. (2) Delete `BuiltinFunctions` from `packages/2-sql/4-lanes/sql-builder/src/expression.ts:62-117` and drop the `BuiltinFunctions &` intersection from `Functions` so the type derives purely from `DeriveExtFunctions` (which carries the 15 family ops via slice 2). (3) Delete `createBuiltinFunctions` from `packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts:137-161` and simplify `createFunctions` Proxy at lines 180-195 to a single registry lookup. (4) Add a new type-d test asserting the cipherstash AC3 trait-tightening (`fns.eq(cipherstashCol, cipherstashCol)` fails type-check; `fns.eq(intCol, intCol)` typechecks). (5) Update the family-sql lowering-parity comments at `packages/2-sql/9-family/src/core/query-operations.ts:15+127` that reference the now-deleted surfaces. **What stays the same.** No edits to ORM model accessor, `COMPARISON_METHODS_META`, `HavingComparisonMethods`, `ComparisonMethods`, or anything under `packages/3-extensions/sql-orm-client/src/`. Workspace tests pass byte-identical SQL output (the family's `eq`/`neq`/etc. produce the same AST as `BuiltinFunctions['eq']` did — slice 2 D2 verified this with the 15-row lowering parity table). + +**Files in play.** + +- `packages/2-sql/4-lanes/sql-builder/src/expression.ts` — MODIFIED. Delete `BuiltinFunctions` lines 62-117; drop intersection from `Functions`. +- `packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts` — MODIFIED. Delete `createBuiltinFunctions` lines 137-161; delete the private helpers it owns (`eq`, `ne`, `comparison`, `inOrNotIn`, `binaryWithSharedCodec`, `resolveOperand`, `toLiteralExpr`, `boolExpr`); simplify `createFunctions` Proxy to single registry lookup. +- `packages/2-sql/4-lanes/sql-builder/test/runtime/functions.test.ts` — MODIFIED. Rename 2 `fns.ne` → `fns.neq`. +- `packages/2-sql/4-lanes/sql-builder/test/cipherstash-trait-tightening.test-d.ts` — NEW (or extension of existing .test-d.ts per spec OQ3). ~30 LoC. Negative test for cipherstash + positive test for codecs with `equality` trait. +- `test/integration/test/sql-builder/subquery.test.ts` — MODIFIED. Rename 1 `fns.ne`. +- `test/integration/test/sql-builder/where.test.ts` — MODIFIED. Rename 1 `fns.ne`. +- `test/integration/test/cli-journeys/invariant-routing.e2e.test.ts` — MODIFIED. Rename 4 `fns.ne`. +- `examples/prisma-next-demo/src/queries/cross-author-similarity.ts` — MODIFIED. Rename 3 `fns.ne`. +- `packages/2-sql/9-family/src/core/query-operations.ts` — MODIFIED. Update lowering-parity doc-comments at lines 15 + 127. Comment-only change; describe the family factory as source-of-truth (not parity destination). + +**"Done when" gates.** + +- [ ] `pnpm --filter @prisma-next/sql-builder build` — clean. +- [ ] `pnpm --filter @prisma-next/sql-builder typecheck` — clean. The new type-d test must compile cleanly with the `@ts-expect-error` on the cipherstash negative case. +- [ ] `pnpm --filter @prisma-next/sql-builder test` — green. +- [ ] 8-package expanded targeted typecheck: `pnpm --filter @prisma-next/operations --filter @prisma-next/sql-contract --filter @prisma-next/sql-orm-client --filter @prisma-next/extension-cipherstash --filter @prisma-next/extension-pgvector --filter @prisma-next/family-sql --filter @prisma-next/sql-runtime --filter @prisma-next/sql-builder typecheck` — clean. +- [ ] `pnpm --filter @prisma-next/sql-orm-client test` — green. The ORM tests use `column.neq` (which never changed); they should pass without modification. +- [ ] Workspace regression test on the relevant package set (sql-builder + sql-orm-client + extension-postgres + extension-sqlite + extension-cipherstash + extension-pgvector + sql-runtime) — green. +- [ ] Demo run: `pnpm demo` (or whatever the project's demo command is) produces byte-identical output to a pre-slice-3 baseline. (The 3 renamed `fns.ne` → `fns.neq` in the demo are semantically identical; the SQL output must match.) +- [ ] `pnpm lint:deps` — clean. +- [ ] **F1 verification grep**: `rg 'BuiltinFunctions|createBuiltinFunctions' packages/ examples/ test/` returns ZERO production hits (test files may carry historical references in their own setup; if any remain, document why). Comment references at `packages/2-sql/9-family/src/core/query-operations.ts:15+127` should be UPDATED, not deleted (per § Files in play). +- [ ] **F3 verification grep**: `rg 'fns\.ne\(' packages/ examples/ test/` returns ZERO hits. (Sanity check: 11 sites renamed.) +- [ ] **NFR2 typecheck-time check**: measure `pnpm typecheck` wall-clock on the demo BEFORE the dispatch (you may need to `git stash` your diff to baseline) and AFTER. Report both numbers. If after > before by more than ~10%, surface to orchestrator with the delta. +- [ ] Intent-validation: `git diff --name-only HEAD` shows only the 9 files in scope. No edits to `model-accessor.ts`, `COMPARISON_METHODS_META`, `HavingComparisonMethods`, or D2's territory. +- [ ] No-transient-IDs grep. +- [ ] Edge cases from slice spec covered by this dispatch: "fns.ne → fns.neq rename" (handled exhaustively); "Cipherstash fns.eq(cipherstashCol, ...) typecheck failure" (new test-d.ts); "Functions typecheck-time (NFR2)" (measured + reported). + +**Size.** M. 9 files; ~140 LoC removed + ~30 LoC new test + 11 mechanical renames; one design judgment (test-d.ts placement + cipherstash trait-tightening assertion shape); cross-package blast radius (sql-builder is consumed everywhere) but the workspace tests + lint:deps + targeted typecheck jointly verify. + +**Model tier.** Sonnet (mid tier). The deletions are mechanical; the type-d test follows ADR 203's `// @ts-expect-error` pattern (cipherstash already has precedent at `packages/3-extensions/cipherstash/test/equality-trait-removal.test.ts`). The judgment-heavy work (preserving lowering parity) was already done in slice 2 D2. + +**DoR confirmed.** ✓ Spec exists; slice 2 closed (registry has `neq` ready to source); intent stated; files-in-play exhaustively named (9 files with site counts); "done when" binary with explicit grep gates; size M; failure modes F1/F3/F5 named; edge cases mapped; NFR2 mitigation specified; downstream packages enumerated. + +### Dispatch 2: ORM accessor collapse — single registry loop, `LEGACY_ORDERING_METHODS`, delete `COMPARISON_METHODS_META` + +**Intent.** Ship the entire ORM-side cleanup. (1) Collapse the two-loop synthesis in `createScalarFieldAccessor` (`packages/3-extensions/sql-orm-client/src/model-accessor.ts:138-167`) to a single registry-driven loop, supplemented by a transient `LEGACY_ORDERING_METHODS` map for `asc`/`desc`. (2) Collapse the second `COMPARISON_METHODS_META` loop inside `createExtensionMethodFactory` (lines 191-196) — rewire to read from the registry filtered by the result codec's traits, preserving the chained-comparison surface. (3) Delete `COMPARISON_METHODS_META` (lines 309-365) along with `ComparisonMethodFns`, `ComparisonMethodMeta`, `MethodFactory`, `scalarComparisonMethod`, `listComparisonMethod` (lines 278-301). PRESERVE `ComparisonMethods` (FR13; trait-filter logic re-sources from the registry) and `HavingComparisonMethods` (slice 4's territory). (4) Update cipherstash doc-comments: `packages/3-extensions/cipherstash/src/execution/operators.ts:39` references `COMPARISON_METHODS_META.eq` — update to reference the family's `eq` instead; `packages/3-extensions/cipherstash/test/equality-trait-removal.test.ts` has a doc-comment referencing `COMPARISON_METHODS_META` — update. **What stays the same.** No edits to sql-builder, family-sql, `HavingComparisonMethods`, `ComparisonMethods` (preserved per FR13). No introduction of `OrderByModelAccessor` or new ORM ordering registry (slice 3b). The per-column ORM method surface is byte-identical for codecs declaring traits (AC4); the only change is structural (registry-sourced not META-sourced). + +**Files in play.** + +- `packages/3-extensions/sql-orm-client/src/model-accessor.ts` — MODIFIED. Two-loop collapse in `createScalarFieldAccessor` + `createExtensionMethodFactory`. Add `LEGACY_ORDERING_METHODS` map (≤ 10 LoC). +- `packages/3-extensions/sql-orm-client/src/types.ts` — MODIFIED. Delete `COMPARISON_METHODS_META` (309-365), `ComparisonMethodFns` and related types (278-301). Preserve `ComparisonMethods` (~line 470) and `HavingComparisonMethods` (~line 514). Re-source `ComparisonMethods`'s trait-filter logic from the registry (likely via the existing `FieldOperations` derivation). +- `packages/3-extensions/cipherstash/src/execution/operators.ts` — MODIFIED. Doc-comment-only update at line 39. +- `packages/3-extensions/cipherstash/test/equality-trait-removal.test.ts` — MODIFIED. Doc-comment-only update. + +**"Done when" gates.** + +- [ ] `pnpm --filter @prisma-next/sql-orm-client build` — clean. +- [ ] `pnpm --filter @prisma-next/sql-orm-client typecheck` — clean. +- [ ] `pnpm --filter @prisma-next/sql-orm-client test` — green. **Particularly important**: the existing query-build integration tests (predicates, ordering, null checks, `in` with lists and subqueries) pass with NO modification. Same test count, same assertions. The implementer cites the pass count. +- [ ] 8-package expanded targeted typecheck — clean. +- [ ] Workspace regression test (sql-builder + sql-orm-client + postgres + sqlite + cipherstash + pgvector + sql-runtime) — green. +- [ ] `pnpm lint:deps` — clean. +- [ ] **F1 verification grep (load-bearing)**: `rg 'COMPARISON_METHODS|ComparisonMethodFns|ComparisonMethodMeta|MethodFactory|scalarComparisonMethod|listComparisonMethod' packages/3-extensions/sql-orm-client/src/` returns ZERO hits **except** the preserved `LEGACY_ORDERING_METHODS` name (which does not match `COMPARISON_METHODS` since it's `LEGACY_ORDERING_METHODS`) and `ComparisonMethods` (which DOES match `ComparisonMethod` — verify the grep is precise enough to allow this single permitted hit). If new names appear (the F1 anti-pattern: rebranding `COMPARISON_METHODS_META` under a different name), surface to orchestrator. +- [ ] **SDoD10 verification grep**: `rg 'LEGACY_ORDERING_METHODS' packages/` returns hits in EXACTLY one file (the ORM model accessor). If it appears in multiple files, surface as scope creep. +- [ ] **AC4 surface-unchanged check**: for at least one column of each test contract codec (pg/int4, pg/text, cipherstash, arktype-json, pg/vector-like), grep or inspect the accessor's method set before and after. The method set must be identical (modulo deliberate AC3 — cipherstash never had `eq`/`neq` here so no change). The implementer reports per-codec method counts. +- [ ] Intent-validation: `git diff --name-only HEAD` shows only the 4 files in scope. No edits to sql-builder, family-sql, `HavingComparisonMethods`, or `ComparisonMethods`'s exported surface. +- [ ] No-transient-IDs grep. +- [ ] Edge cases from slice spec covered by this dispatch: "asc/desc preservation via LEGACY_ORDERING_METHODS"; "ORM column accessor surface unchanged (AC4)"; "createExtensionMethodFactory non-predicate result-method synthesis collapse"; "Transient .asc/.desc leak on WHERE accessor" (documented via the LEGACY_ORDERING_METHODS comment); "HavingComparisonMethods stays in place"; "ComparisonMethods preserved"; "Extensions referencing deleted symbols" (doc-comment updates). + +**Size.** M. 4 files; ~120 LoC removed + ~15 LoC added (`LEGACY_ORDERING_METHODS` + comment + minor type changes) + ~25 LoC of synthesis-loop refactoring = ~160 LoC; one design judgment (the registry-driven re-implementation of `ComparisonMethods`'s trait-filter); blast radius confined to sql-orm-client + cipherstash doc-comments. The largest dispatch in the slice but still within M. + +**Model tier.** Opus (orchestrator tier). The judgment-heavy work — preserving trait-filter logic while collapsing the two-loop synthesis, handling the non-predicate result-method factory rewire, ensuring `ComparisonMethods` derivation reads from the registry without changing its published surface — requires careful reasoning. A wrong cut here breaks the AC4 promise silently. + +**DoR confirmed.** ✓ Depends on D1 closed (sql-builder cleanup must have landed so the workspace regression test's baseline is post-rename; otherwise the existing tests would already fail). Intent stated; files exhaustively named; "done when" binary with grep gates (including the load-bearing F1 grep); size M; failure modes F1/F3/F5 named; edge cases mapped; AC4 surface-unchanged check has a concrete verification protocol. + +### Dispatch 3: Manual-QA — author + run + record + +**Intent.** This is the slice-closing dispatch — code is fully landed after D2; D3's value-add is the **manual-QA discipline** the slice triggers because of the user-visible cipherstash trait tightening. Two atomic-skill invocations: + +1. **`drive-qa-plan`** — author `projects/unify-query-operations/slices/collapse-consumers/manual-qa.md`. Script targets the **extension-author audience** primarily (cipherstash tightening affects them); single-audience declaration. Coverage probes per slice-spec SDoD4: (a) verify `fns.eq(cipherstashCol, cipherstashCol)` produces a typecheck error with a useful diagnostic message; (b) verify `fns.eq(intCol, intCol)` still typechecks; (c) verify the demo's renamed `fns.neq` calls produce byte-identical SQL output to the pre-rename baseline; (d) verify `m.field.asc()` still works through `LEGACY_ORDERING_METHODS` (regression check on the transient preservation). + +2. **`drive-qa-run`** — execute the script end-to-end; record findings in `projects/unify-query-operations/slices/collapse-consumers/manual-qa-run-.md`. No 🛑 Blocker findings allowed for SDoD4 PASS; ⚠️ Should-fix findings get triaged by orchestrator (either fix in a follow-up dispatch within slice 3 or file as out-of-scope). + +**What stays the same.** No code changes in D3; this is documentation + observation only. + +**Files in play.** + +- `projects/unify-query-operations/slices/collapse-consumers/manual-qa.md` — NEW. ~30-50 LoC markdown. +- `projects/unify-query-operations/slices/collapse-consumers/manual-qa-run-.md` — NEW. ~20-40 LoC markdown run report. + +**"Done when" gates.** + +- [ ] `manual-qa.md` exists at the named path, follows the `drive-qa-plan` template, names the extension-author audience explicitly, covers all 4 probes from the slice spec. +- [ ] `manual-qa-run-.md` exists at the named path, follows the `drive-qa-run` template, records the outcome of each of the 4 probes. +- [ ] No 🛑 Blocker findings. +- [ ] Each ⚠️ Should-fix finding (if any) is surfaced to the orchestrator with a recommended disposition. +- [ ] Edge cases from slice spec covered by this dispatch: "Manual-QA script REQUIRED — cipherstash trait tightening is user-visible" (this dispatch IS the manual-QA); "Demo migration risk" (probe (c) verifies demo SQL output unchanged). + +**Size.** S. Two markdown files; ~50-90 LoC total; no code; one design judgment (script structure per `drive-qa-plan`). + +**Model tier.** Sonnet (mid tier). The `drive-qa-plan` skill provides the template structure; the `drive-qa-run` skill provides the execution protocol. The judgment is in probe design (4 probes well-targeted at user-visible surface) and observation quality. + +**DoR confirmed.** ✓ Depends on D2 closed (full code lands; the manual-QA exercises real behaviour). Intent clear; files-in-play named (two markdown files); "done when" gates binary; size S; failure modes — F5 (no destructive git during QA — the script doesn't touch git; the QA-run can produce findings that prompt further code changes via a re-opened D2 round, but D3 itself doesn't commit code). + +## Dependencies between dispatches + +Sequential stack: D1 → D2 → D3. + +- **D2 depends on D1.** Strictly speaking D2 and D1 touch different files (sql-builder vs ORM), so they're code-independent. But the workspace regression test in D2's "Done when" depends on `fns.ne` renaming being complete (D1's work) — otherwise existing `fns.ne` callsites would still typecheck-fail at the moment D2 runs. Sequential serialization keeps the workspace green at every commit. +- **D3 depends on D2.** Manual-QA needs the full code (including the cipherstash tightening which only fires after D1 and the LEGACY_ORDERING_METHODS preservation which only exists after D2). + +No parallelization opportunity within this slice. + +## Cross-references + +### Failure modes threaded + +- **F1 — Dual-shape support relocated under a new name.** D2 implementer might be tempted to recreate `COMPARISON_METHODS_META`-equivalent functionality under a new name (e.g. `LEGACY_COMPARISON_METHODS`, `BUILTIN_OP_FACTORIES`, etc.) inside `model-accessor.ts` to preserve the synthesis pattern. The grep gate at D2's "Done when" specifically catches this — any new `COMPARISON_METHODS`-style name appearing in the diff is a stop-condition. The `LEGACY_ORDERING_METHODS` map is the ONLY accepted preservation, and it's scoped to asc/desc (a 2-entry map with explicit "removed by slice 3b" annotation). Reference: [F1 in failure-modes.md](../../../../drive/calibration/failure-modes.md#f1-dual-shape-support-relocated-under-a-new-name). + +- **F3 — Discovery via test suite instead of grep.** Each dispatch's consumer discovery is pre-grounded in the slice spec (the 11 `fns.ne` sites are named exhaustively; the 7 files referencing deleted symbols are named). Implementers re-grep at pre-flight to confirm no new sites appeared since the slice-spec snapshot. + +- **F5 — Destructive git operations forbidden.** Standard prohibition across all three dispatches. + +### Grep library entries used + +- `rg 'fns\.ne\(' packages/ examples/ test/` — D1 final gate (0 hits after rename). +- `rg 'BuiltinFunctions|createBuiltinFunctions' packages/ examples/ test/` — D1 final gate (0 production hits; doc-comment hits in `family-sql/src/core/query-operations.ts` UPDATED, not deleted). +- `rg 'COMPARISON_METHODS|ComparisonMethodFns|ComparisonMethodMeta|MethodFactory|scalarComparisonMethod|listComparisonMethod' packages/3-extensions/sql-orm-client/src/` — D2 final gate, F1 anti-pattern check. +- `rg 'LEGACY_ORDERING_METHODS' packages/` — D2 SDoD10 gate (exactly one file). +- `rg 'BuiltinFunctions' packages/2-sql/9-family/src/core/query-operations.ts` — D1 confirms doc-comment updates (the comments either describe history without the name, or the name is wrapped in a "previously known as" past-tense framing). + +## Slice-DoD reachability + +Every condition in the slice-DoD is covered by one or more dispatches: + +| Slice-DoD condition | Covered by | +|---|---| +| **SDoD1.** All gates pass. | All three dispatches contribute; final pass on D2 (the workspace regression). D3's manual-QA gate is separate. | +| **SDoD2.** Every pre-named edge case handled per its disposition. | Distributed per the edge-cases-covered tables in each dispatch. | +| **SDoD3.** Reviewer verdict: accept. | D3's reviewer round is the slice-level verdict. | +| **SDoD4.** Manual-QA script + run report + no Blocker findings. | D3 (the entire dispatch's value-add). | +| **SDoD5.** No out-of-scope touches. | Intent-validation gate in each dispatch + the explicit "Files in play" enumeration. | +| **SDoD6.** AC1 — legacy surfaces gone (repo-wide search). | D1 (sql-builder deletion grep) + D2 (sql-orm-client deletion grep). | +| **SDoD7.** AC3 — trait gating symmetric. | D1 (the cipherstash tightening test). | +| **SDoD8.** AC4 — per-column ORM method surface unchanged. | D2 (the surface-unchanged check). | +| **SDoD9.** AC9 — end-to-end ORM queries build, byte-identical SQL. | D2 (workspace regression test pass). | +| **SDoD10.** `LEGACY_ORDERING_METHODS` in exactly one file. | D2 (the SDoD10 grep gate). | +| **SDoD11.** `fns.ne` gone from production code. | D1 (the `rg 'fns\.ne\('` gate). | + +## Risks + +1. **D1 NFR2 typecheck-time regression.** Removing the `BuiltinFunctions &` intersection in `Functions` could regress `pnpm typecheck` wall-clock on the demo. D1's "Done when" gate measures this. If regression > ~10%, the project spec's mitigation is "investigate shared `infer` slots / distributive conditionals." If the implementer surfaces a regression, the orchestrator may need to amend D1's brief (or open a follow-up round in slice 3) to add the mitigation. + +2. **D2 AC4 surface-unchanged check methodology.** The "per-codec method count must match before and after" check requires the implementer to baseline the per-codec accessor surface pre-D2. The protocol: stash D2's diff, run a small test that inspects `Object.keys(accessor[col])` for each codec, record counts, unstash, re-run, diff. If the counts don't match, surface to orchestrator. If they match but a method name changed, surface — methods must be present under the same name. + +3. **D2 F1 anti-pattern.** The grep gate catches `COMPARISON_METHODS`-style names but not all conceivable rebranding (e.g. `STATIC_OPERATIONS`, `INLINE_METHOD_REGISTRY`). The reviewer's intent-validation must read the D2 diff for net-new factories of any kind. The slice-DoD's "no out-of-scope touches" + the intent-validation gate jointly cover this; the reviewer is expected to spot-check. + +4. **D2 `ComparisonMethods` derivation re-sourcing.** `ComparisonMethods` is the public-facing type wrapper that today filters its method set by reading from `COMPARISON_METHODS_META`. After deletion, it must re-source the filter from the registry-derived `FieldOperations` (or equivalent). The re-sourcing is a type-level refactor that's easy to get subtly wrong (e.g. omitting `isNull`/`isNotNull` from the resulting union). The reviewer must spot-check the type-level surface against the AC4 promise. + +5. **D3 manual-QA Blocker finding scenarios.** If the cipherstash tightening test surfaces a less-than-useful diagnostic message (e.g. TypeScript's error is opaque), the manual-QA may surface this as a ⚠️ Should-fix or 🛑 Blocker. The disposition is the orchestrator's call: improve the error in this slice (adding a `& { __error_hint?: 'cipherstash codec does not declare equality trait' }` type-level hint, for example), defer to a follow-up, or accept the diagnostic as-is. The slice-DoD says "no 🛑 Blocker findings" — a Blocker means re-opening D1 or D2. + +6. **Workspace test set boundary.** D1 and D2 each run a workspace regression test on the same package set (sql-builder + sql-orm-client + postgres + sqlite + cipherstash + pgvector + sql-runtime). If a regression slips through D1 but D1 PASSED, D2's regression run catches it as "regression introduced by D1's commits." The orchestrator then re-opens D1, not D2. The reviewer should distinguish "D2 introduced this" from "D1 introduced this and D1's gate missed it" via `git blame` / commit inspection. diff --git a/projects/unify-query-operations/slices/collapse-consumers/spec.md b/projects/unify-query-operations/slices/collapse-consumers/spec.md new file mode 100644 index 0000000000..ba2c07eea8 --- /dev/null +++ b/projects/unify-query-operations/slices/collapse-consumers/spec.md @@ -0,0 +1,244 @@ +# Slice: collapse-consumers + +_Parent project: [`projects/unify-query-operations/`](../../). This slice satisfies FR6-FR14 from the project spec (the legacy-surface deletions and consumer rewiring; the AC1/AC3/AC4/AC5/AC7/AC9 promises). **Narrowed per operator decision 2026-05-21** — the orderBy callback accessor split + ORM ordering registry are deferred to slice 3b._ + +## At a glance + +Delete `COMPARISON_METHODS_META` (ORM) and `BuiltinFunctions` (sql-builder). Collapse the ORM model accessor's two-loop synthesis to a single registry-driven loop. Drop the `BuiltinFunctions &` intersection from `Functions` so the sql-builder `fns` proxy derives purely from `DeriveExtFunctions` — which now (post slice 2) includes all 15 family operations. **User-visible result**: `fns.eq(cipherstashCol, cipherstashCol)` fails type-checking on the sql-builder surface (symmetric with the ORM's pre-existing behaviour); the sql-builder's `fns.ne` is renamed to `fns.neq` (the family adopted `neq` to match the ORM's existing wording). **Transient state**: the WHERE-style column accessor still exposes cosmetic `.asc()` / `.desc()` methods through a tiny hand-listed `LEGACY_ORDERING_METHODS` map preserved in `model-accessor.ts` — slice 3b's territory removes the leak by introducing the proper ORM ordering registry and splitting the orderBy callback accessor from WHERE. + +## Scope + +### In scope + +**Deletions:** + +- `packages/3-extensions/sql-orm-client/src/types.ts` — delete `COMPARISON_METHODS_META` (lines 309-365), `ComparisonMethodMeta` (lines 284-287), `ComparisonMethodFns` and the related `scalarComparisonMethod` / `listComparisonMethod` / `MethodFactory` definitions (lines 278-301). **Preserve** `ComparisonMethods` (the public-facing type wrapper at lines ~470-510 — FR13 requires it stays; its trait-filter logic gets re-sourced from the registry). +- `packages/2-sql/4-lanes/sql-builder/src/expression.ts` — delete `BuiltinFunctions` (lines 62-117). Drop the `BuiltinFunctions &` intersection from `Functions` (the resulting type derives purely from `DeriveExtFunctions` — FR14). +- `packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts` — delete `createBuiltinFunctions` (lines 137-161) and the private helpers it owns (`eq`, `ne`, `comparison`, `inOrNotIn`, `binaryWithSharedCodec`, `resolveOperand`, `toLiteralExpr`, `boolExpr` — those exact helpers are now redundant; D2 of slice 2 copied verbatim equivalents into `packages/2-sql/9-family/src/core/query-operations.ts`). Modify the `createFunctions` Proxy (lines 180-195) to perform a single registry lookup (no fall-through to builtins). + +**Consumer rewires:** + +- `packages/3-extensions/sql-orm-client/src/model-accessor.ts` — collapse the two-loop synthesis in `createScalarFieldAccessor` (lines 138-167). The current shape: loop 1 (lines 149-153) over `COMPARISON_METHODS_META` filtered by codec traits + loop 2 (lines 162-164) over the registry's per-codec index. The new shape: **single loop** over the registry's per-codec index (which now carries the 15 family ops from slice 2). The same collapse applies inside `createExtensionMethodFactory` for non-predicate result-method synthesis (lines 191-196) — the second `COMPARISON_METHODS_META` loop there must read from the registry-and-filter-by-return-codec-traits pattern instead. **Preserve** `asc` / `desc` via a tiny `LEGACY_ORDERING_METHODS` map (~8 LoC) — see § Approach for details. +- `packages/3-extensions/cipherstash/test/equality-trait-removal.test.ts` — update the doc-comment that references `COMPARISON_METHODS_META` (it's a comment, not a code reference; the test itself continues to assert what it asserts). +- `packages/3-extensions/cipherstash/src/execution/operators.ts` — update the doc-comment at line 39 that references `COMPARISON_METHODS_META.eq` to reference the family's `eq` (via the registry) instead. Comment-only change; no behavioural impact. +- `packages/2-sql/9-family/src/core/query-operations.ts` — update doc-comments at lines 15 and 127 that reference the legacy surfaces (D2 wrote these as lowering-parity notes; with the surfaces gone, the comments should describe the family factory as the source of truth rather than the parity destination). + +**`fns.ne` → `fns.neq` rename (consumer-side migration):** + +The family registers `neq` (matching `COMPARISON_METHODS_META`'s existing wording; slice 2 § Edge cases pinned this). Today's sql-builder publishes `fns.ne`. After this slice, the sql-builder publishes `fns.neq` (sourced from the registry); existing `fns.ne` callers must be renamed. + +Files to touch (~11 hits across 5 files, grounded by `rg 'fns\.ne\b'`): + +- `packages/2-sql/4-lanes/sql-builder/test/runtime/functions.test.ts` — 2 hits. +- `test/integration/test/sql-builder/subquery.test.ts` — 1 hit. +- `test/integration/test/sql-builder/where.test.ts` — 1 hit. +- `test/integration/test/cli-journeys/invariant-routing.e2e.test.ts` — 4 hits. +- `examples/prisma-next-demo/src/queries/cross-author-similarity.ts` — 3 hits. + +Each migration is a mechanical rename `fns.ne(` → `fns.neq(`. + +### Out of scope (this slice) + +**Slice 3b's territory** (the orderBy accessor split): + +- Introducing the private ORM ordering registry with `asc` / `desc` — slice 3b ships this. +- Splitting the `orderBy` callback accessor from the WHERE-style column accessor — slice 3b's `OrderByModelAccessor` lands then. +- Removing the `LEGACY_ORDERING_METHODS` transient map — slice 3b deletes it once the proper ordering registry is wired. +- Narrowing the WHERE accessor's published type to omit `asc` / `desc` — slice 3b. + +**Other slices:** + +- HAVING surface derivation (delete `HavingComparisonMethods`; derive from registry) — slice 4. +- ADR drafting — slice 5. +- Aggregate-only functions (`count`, `sum`, `avg`, `min`, `max`) — project non-goal (declared in project spec § Non-goals). + +## Approach + +The slice has three logical workstreams that compose into one PR's diff: **(1) sql-builder cleanup** (delete `BuiltinFunctions`, `createBuiltinFunctions`; rewire the `fns` proxy + `Functions` type), **(2) ORM model accessor collapse** (single registry loop in `createScalarFieldAccessor` and `createExtensionMethodFactory`; transient `LEGACY_ORDERING_METHODS` map for `asc` / `desc`; delete `COMPARISON_METHODS_META`), **(3) `fns.ne` → `fns.neq` rename** + cipherstash doc-comment updates. The slice plan will decompose these into ~3 M-sized dispatches. + +**Transient state for asc/desc** (load-bearing design call): + +`asc` / `desc` live in `COMPARISON_METHODS_META` today (lines 349-356, declared with `traits: ['order']`). Slice 2 deliberately excluded them from the SQL family registry — they're slice 3b's territory (the proper ORM ordering registry). If slice 3 deletes `COMPARISON_METHODS_META` outright, orderBy callbacks break. + +The transient preservation strategy: introduce a tiny `LEGACY_ORDERING_METHODS` map private to `packages/3-extensions/sql-orm-client/src/model-accessor.ts` (or a sibling file), carrying only `asc` and `desc` and their `OrderByItem.asc` / `OrderByItem.desc` factory closures (copied verbatim from `COMPARISON_METHODS_META`). The model accessor's synthesis loop reads `asc`/`desc` from this map alongside the registry-driven ops. The map is annotated with a "**Removed by slice 3b** — when the ORM ordering registry lands" comment so future readers know it's deliberately transient. + +```ts +// Illustrative — placement is the implementer's choice. +// Slice-3b removes this in favour of the proper ORM ordering registry. +const LEGACY_ORDERING_METHODS = { + asc: { traits: ['order' as const], create: (left: AnyExpression) => () => OrderByItem.asc(left) }, + desc: { traits: ['order' as const], create: (left: AnyExpression) => () => OrderByItem.desc(left) }, +} as const; +``` + +The orderBy callback continues to receive the same `ModelAccessor` shape it does today — `m.field.asc()` / `m.field.desc()` keep working. The cosmetic leak (the WHERE-style accessor also exposes them) is the transient state slice 3b removes by splitting accessors. + +**ORM model accessor's two-loop collapse:** + +Today's `createScalarFieldAccessor` (model-accessor.ts:138-167) has two synthesis loops: + +```ts +// Loop 1: legacy COMPARISON_METHODS_META, filtered by codec traits. +for (const [name, meta] of Object.entries(COMPARISON_METHODS_META)) { + if (meta.traits.some((t) => !traits.includes(t))) continue; + comparisonEntries.push([name, meta.create(column, codec)]); +} + +// Loop 2: registry's per-codec index (extension + slice-2 family entries). +for (const [name, entry] of operations) { + accessor[name] = createExtensionMethodFactory(accessor, entry, context); +} +``` + +After this slice the synthesis collapses to a single loop over the registry's per-codec index, with `LEGACY_ORDERING_METHODS` as a separate fixed-set surface for `asc`/`desc`: + +```ts +// Illustrative — single registry-driven loop. +for (const [name, entry] of operations) { + accessor[name] = createExtensionMethodFactory(accessor, entry, context); +} +// Transient — slice 3b removes when the ORM ordering registry lands. +for (const [name, meta] of Object.entries(LEGACY_ORDERING_METHODS)) { + if (meta.traits.some((t) => !traits.includes(t))) continue; + accessor[name] = meta.create(column); +} +``` + +The same collapse applies inside `createExtensionMethodFactory` (lines 191-196) for non-predicate result-method synthesis. Today: loops over `COMPARISON_METHODS_META` filtered by the *result* codec's traits. After: same logic, but reads from the registry's index by the result codec id (filtered by predicate-return shape, since non-predicate methods on a non-predicate result are chainable comparisons). Implementer must verify that the registry's per-codec index returns the right set for an arbitrary result codec id — slice 2's family ops are indexed per-codec; the operation entries' `impl` signatures take the right argument types. + +**sql-builder `Functions` simplification:** + +Today's type at `expression.ts:62-117`: + +```ts +// Illustrative. +export type Functions = BuiltinFunctions> + & DeriveExtFunctions + & AggregateFunctions; +``` + +After this slice: + +```ts +export type Functions = DeriveExtFunctions + & AggregateFunctions; +``` + +`AggregateFunctions` stays — it's the aggregate-only surface (`count`/`sum`/`avg`/`min`/`max`). The aggregate-only deletion is a project non-goal. + +The `createFunctions` Proxy in `runtime/functions.ts:180-195` likewise simplifies: + +```ts +// Today. +export function createFunctions( + operations: Readonly>, +): Functions { + const builtins = createBuiltinFunctions(); + return new Proxy({} as Functions, { + get(_target, prop: string) { + const builtin = (builtins as Record)[prop]; + if (builtin) return builtin; + const op = operations[prop]; + if (op) return op.impl; + return undefined; + }, + }); +} + +// After. +export function createFunctions( + operations: Readonly>, +): Functions { + return new Proxy({} as Functions, { + get(_target, prop: string) { + return operations[prop]?.impl; + }, + }); +} +``` + +**Cipherstash trait tightening (user-visible AC3 win):** + +The project spec § Approach explains this as a "deliberate behaviour change, not an accident of the refactor." Today's sql-builder `fns.eq` is parametric over any codec id and any expression — `fns.eq(cipherstashCol, cipherstashCol)` typechecks. After this slice, `fns.eq` derives its argument-type constraint from the registry's `eq` entry, which declares `self: { traits: ['equality'] }`. The trait-constrained codec-id generic resolves to the union of codec ids in `CT` that declare `equality` — cipherstash's `cipherstash/string@1` codec declares `traits: []` (extension-namespaced traits like `cipherstash:equality` don't satisfy the framework-canonical `equality` requirement), so it's not in the union. `fns.eq(cipherstashCol, ...)` becomes a TypeScript error. + +The `equality-trait-removal.test.ts` is a cipherstash test that asserts this exact tightening for the ORM side — today it asserts `column.eq` is absent on cipherstash columns because their codec opts out. After this slice, the same tightening applies to the sql-builder surface; the test's narrative gets richer. The test file gets ONE comment update (no test logic change in slice 3 — the test was already correct, just the doc-comment referencing `COMPARISON_METHODS_META` updates). + +## Edge cases (Example-Mapping) + +| Edge case | Disposition | Notes | +|---|---|---| +| `fns.ne` → `fns.neq` rename across 5 files / ~11 sites | Handle | Mechanical `rg`-based migration. Slice spec lists the call sites exhaustively; the implementer re-runs the grep before declaring done to confirm zero remaining `fns\.ne\(` references. | +| `asc` / `desc` preservation via `LEGACY_ORDERING_METHODS` | Handle | A 4-entry transient map (asc + desc factories) inside `model-accessor.ts`. The comment block says "Removed by slice 3b — proper ORM ordering registry lands then." A grep gate confirms `LEGACY_ORDERING_METHODS` appears in exactly one file (no stray copies). | +| Cipherstash `fns.eq(cipherstashCol, cipherstashCol)` typecheck failure (user-visible AC3) | Handle | Deliberate behaviour change. A new type-level test in the sql-builder package asserts the failure (`// @ts-expect-error`); a positive type-level test asserts `fns.eq(intCol, intCol)` still typechecks. The manual-QA script verifies the developer-experience side (typecheck error message quality, no `any`-cast escape hatch). | +| ORM column accessor surface unchanged for codecs that declare traits (AC4) | Handle | The single-loop synthesis sources its op set from the registry, which (post slice 2) carries the same 15 ops trait-gated per the same trait sets. For any codec declaring `equality + order + textual` (e.g. `pg/text@1`), the per-column method surface is byte-identical. A test fixture exercises a representative codec set and asserts the method surface matches the pre-slice baseline (modulo the deliberate cipherstash tightening). | +| `HavingComparisonMethods` stays in place (slice 4's deletion target) | Explicitly out | Slice 4 deletes it. This slice MUST NOT touch it — intent-validation gate confirms. The Pick<...> type at sql-orm-client/src/types.ts (line ~514) is left as-is. | +| `createExtensionMethodFactory` non-predicate result-method synthesis | Handle | Lines 191-196 today loop over `COMPARISON_METHODS_META`. The collapse replaces this with a registry-driven loop filtered by the result codec's traits. Same trait-filter logic; registry-sourced ops; covers the chained-comparison surface end-to-end. Test: a non-predicate registry-defined operation's return type's `.eq()` method must still work after the slice (existing tests cover this in sql-orm-client). | +| Intent-validation: no edits to `family-sql/**` | Handle | Slice 2 sealed the family. Intent-validation gate confirms `git diff --name-only` shows zero edits under `packages/2-sql/9-family/src/` (except possibly the doc-comment updates at `core/query-operations.ts` lines 15 and 127, which are scoped and trivial). | +| Workspace test regression check (no behaviour change beyond AC3 + the rename) | Handle | The workspace test suite must pass with ZERO modifications EXCEPT the `fns.ne → fns.neq` rename + the cipherstash test's doc-comment. Any new failure that isn't explained by these two changes is a regression that halts the dispatch. SDoD9. | +| Transient `.asc()` / `.desc()` leak on WHERE accessor | Defer | Documented; slice 3b's territory. Manual-QA script for this slice notes the cosmetic leak as a known limitation; users get `m.field.asc()` in WHERE callbacks but it would semantically only be useful in orderBy — same as today. | +| Extensions referencing deleted symbols | Handle | Grounded by `rg`: cipherstash's `execution/operators.ts:39` (doc-comment only — update) and `equality-trait-removal.test.ts` (doc-comment only — update). No extension under `packages/3-extensions/*` references the deleted symbols in code. Adapter packages (`packages/3-targets/*`) confirmed clean by grep. | +| `Functions` typecheck time (NFR2) | Handle | Removing the `BuiltinFunctions &` intersection means TypeScript no longer resolves the 13-entry handwritten union; it derives from the registry-driven `DeriveExtFunctions` which now has 15 family entries plus any extension entries. The implementer reports `pnpm typecheck` wall-clock before and after on the demo to confirm no regression > a few percent. If it regresses, the project spec's NFR2 mitigation is to investigate shared `infer` slots / distributive conditionals before shipping. | +| Demo + examples migration | Handle | The `examples/prisma-next-demo/src/queries/cross-author-similarity.ts` is in the `fns.ne` migration list. End-to-end `pnpm demo` should still produce identical output after the rename. | +| ORM model accessor's resolution loop preservation | Handle | The two-loop collapse must preserve the existing trait-filtering logic: only expose method `K` on a codec if the codec's `traits` set includes the operation's required traits. This is the AC4 promise. Test: a column with empty traits (e.g., cipherstash-style) shows only the `any: true` ops (`isNull`/`isNotNull` from slice 2) and the `asc`/`desc` ops gated by `traits: ['order']` (so cipherstash empty-traits sees only isNull/isNotNull). | + +## Contract impact + +**None.** The contract's `queryOperationTypes` slot is unchanged — the family entries from slice 2 remain. The change is purely consumer-side: ORM and sql-builder both source from the registry now. No downstream contract or extension breaks. + +Verification: `rg 'queryOperationTypes' packages/3-extensions/cipherstash/src/ packages/3-extensions/pgvector/src/ packages/3-targets/` shows existing extension/adapter usage; the slice does not alter any of these. + +## Adapter impact + +**Low — verified by grep.** No adapter package (`packages/3-targets/*`) references `COMPARISON_METHODS_META`, `BuiltinFunctions`, `ComparisonMethodFns`, or `createBuiltinFunctions`. Adapters author their own `queryOperations()` factories via the registry pattern; the deletion does not touch their surface. + +The only cross-domain reference is from the family's own descriptor-meta and `query-operations.ts` (slice 2 work) which reference the legacy surfaces only in doc-comments — those comment updates land in this slice as housekeeping. + +## ADR pointer + +Defers to slice 5's close-out ADR ("ADR NNN — Unified SQL-family operation registry"), per the project plan. This slice does not draft a separate ADR; the architectural shift's most user-visible consequence (the cipherstash trait tightening) is documented inline in the close-out ADR alongside slices 1, 2, 3b, and 4. + +## Slice Definition of Done + +- [ ] **SDoD1.** All "Done when" gates from the slice plan pass: `pnpm typecheck` clean on the expanded 8-package targeted set (`operations`, `sql-contract`, `sql-orm-client`, `cipherstash`, `pgvector`, `family-sql`, `sql-runtime`, `sql-builder`); `pnpm test:packages` workspace-wide green; `pnpm lint:deps` clean; intent-validation confirms diff matches slice scope (no edits to `family-sql` src code beyond doc-comment updates; no edits to `HavingComparisonMethods`; no introduction of the ORM ordering registry — slice 3b's territory). +- [ ] **SDoD2.** Every pre-named edge case handled per its disposition. +- [ ] **SDoD3.** Reviewer verdict: accept on `projects/unify-query-operations/reviews/code-review.md`. +- [ ] **SDoD4.** Manual-QA script in `projects/unify-query-operations/slices/collapse-consumers/manual-qa.md`, with ≥ 1 run report, no unresolved 🛑 Blocker findings. The script targets the extension-author audience primarily (cipherstash tightening), with a single-audience declaration in the script's "What this script is testing" block. Coverage includes: (a) verify `fns.eq(cipherstashCol, cipherstashCol)` produces a typecheck error with a useful diagnostic message; (b) verify `fns.eq(intCol, intCol)` still typechecks; (c) verify the demo's renamed `fns.neq` calls produce byte-identical SQL output to the pre-rename baseline; (d) verify the orderBy callback's `m.field.asc()` still works through `LEGACY_ORDERING_METHODS` (regression check on the transient preservation). +- [ ] **SDoD5.** Slice doesn't touch out-of-scope surfaces. Specifically: no introduction of `OrderByModelAccessor` or any new ORM ordering registry (slice 3b); no deletion of `HavingComparisonMethods` (slice 4); no changes to `family-sql/src/core/`, `family-sql/src/types/`, `family-sql/src/exports/`, `family-sql/src/core/runtime-descriptor.ts`, or `family-sql/src/core/control-descriptor.ts` (slice 2 sealed). Doc-comment updates to `family-sql/src/core/query-operations.ts` lines 15 and 127 ARE in scope (they reference the now-deleted surfaces). +- [ ] **SDoD6.** AC1 (legacy surfaces gone — repo-wide search shows zero production references). The grep gate: `rg '\bCOMPARISON_METHODS_META\b|\bBuiltinFunctions\b|\bComparisonMethodFns\b|\bcreateBuiltinFunctions\b' packages/ examples/ test/` returns ZERO production hits (test files may carry historical references in their own setup; the implementer reports each remaining hit and confirms it's deliberate). +- [ ] **SDoD7.** AC3 (trait gating symmetric). A type-level test asserts `fns.eq(cipherstashCol, cipherstashCol)` fails type-checking. A symmetric positive test asserts `fns.eq(intCol, intCol)` typechecks. Both tests live in `packages/2-sql/4-lanes/sql-builder/test/` (new test-d.ts file or extension of an existing one). +- [ ] **SDoD8.** AC4 (per-column ORM method surface unchanged). The implementer reports the method surface diff per codec (a quick `Object.keys` comparison) across the test contracts — pg core codecs, cipherstash, arktype-json, pg/vector-like. The diff is empty except for cipherstash (which loses `eq`/`neq` per AC3 — already matching the ORM's pre-slice behaviour, so no regression). +- [ ] **SDoD9.** AC9 (end-to-end ORM queries still build and emit correct SQL). The existing query-build integration tests (predicates, ordering, null checks, `in` with lists and subqueries) pass with no modification. The implementer cites the suite's pass count before and after. +- [ ] **SDoD10.** `LEGACY_ORDERING_METHODS` map is in exactly one file (the ORM model accessor) and carries the "removed by slice 3b" comment. A grep gate confirms. +- [ ] **SDoD11.** `fns.ne` is gone from production code. A grep gate `rg 'fns\.ne\b' packages/ examples/ test/` returns zero hits (all renamed to `fns.neq`). + +## Open Questions + +1. **Where to place `LEGACY_ORDERING_METHODS`?** Working position: inside `packages/3-extensions/sql-orm-client/src/model-accessor.ts` (top of file or a sibling helper file), since it's a model-accessor-internal concern. Alternative: a sibling file `legacy-ordering.ts` to make slice 3b's deletion mechanical. Either is fine; the implementer chooses. The grep gate at SDoD10 enforces "exactly one file." +2. **`LEGACY_ORDERING_METHODS` typing — `MethodFactory` or inlined?** The original `COMPARISON_METHODS_META` typed factories as `MethodFactory` (= `(left: AnyExpression, codec: CodecRef | undefined) => (...args: never[]) => unknown`). When `MethodFactory` is deleted, the `LEGACY_ORDERING_METHODS` map will need its own minimal local type. Working position: inline the factory shape in the map's type literal — fewer indirections, easier to grep-delete in slice 3b. The orderBy factories don't use the `codec` param, so the signature can simplify to `(left: AnyExpression) => () => OrderByItem`. +3. **Cipherstash tightening test placement.** Working position: a new `.test-d.ts` file at `packages/2-sql/4-lanes/sql-builder/test/cipherstash-trait-tightening.test-d.ts` (negative-cipherstash typecheck) + a positive case at `test/fns-trait-gating.test-d.ts` (or extension of an existing test-d.ts in the same package). Implementer may choose to consolidate into a single file. The point is to surface the AC3 promise as a build-time check. +4. **Demo migration risk.** `examples/prisma-next-demo/src/queries/cross-author-similarity.ts` uses `fns.ne` 3 times. Renaming is mechanical, but a `pnpm demo` end-to-end run is part of the manual-QA verification — if the demo produces different output after the rename (it shouldn't — `eq`/`neq` are equivalent), the rename caused a regression. Working position: include the demo run in the manual-QA script. +5. **`NFR2` typecheck-time regression risk.** Removing the `BuiltinFunctions &` intersection might regress typecheck time. The project spec's mitigation is "shared `infer` slots, distributive conditional types." Working position: measure first; if regression > a few percent on the demo's `pnpm typecheck`, investigate before shipping. The implementer measures and reports. + +## References + +- Parent project: [`../../spec.md`](../../spec.md) — FR6-FR14, AC1/AC3/AC4/AC5/AC7/AC9. Project plan slice 3 description (narrowed). +- Linear issue: TML-2354 (project-level; no per-slice sub-issue). Per the project plan's amended delivery model (single PR at project close), this slice does not open its own PR. +- Slice 2 (`family-ops-factory`) — closed 2026-05-21 — the dependency that put 15 family ops in the registry for this slice's consumers to read. +- ADR 203 / ADR 206 — referenced by the project spec; the patterns this slice's consumers now read from instead of the deleted surfaces. +- In-repo touchpoints (anchors for the slice plan): + - `packages/3-extensions/sql-orm-client/src/types.ts:309-365` — `COMPARISON_METHODS_META` (to delete most of). + - `packages/3-extensions/sql-orm-client/src/types.ts:278-301` — `MethodFactory`, `ComparisonMethodMeta`, `scalarComparisonMethod`, `listComparisonMethod` (to delete). + - `packages/3-extensions/sql-orm-client/src/types.ts` near line 514 — `HavingComparisonMethods` (PRESERVE — slice 4's territory). + - `packages/3-extensions/sql-orm-client/src/types.ts` near line 470 — `ComparisonMethods` (PRESERVE — FR13). + - `packages/3-extensions/sql-orm-client/src/model-accessor.ts:138-167` (`createScalarFieldAccessor` two-loop) + `:191-196` (`createExtensionMethodFactory` non-predicate loop). + - `packages/2-sql/4-lanes/sql-builder/src/expression.ts:62-117` — `BuiltinFunctions` and `Functions` intersection. + - `packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts:137-161` (`createBuiltinFunctions`) + `:180-195` (`createFunctions` Proxy). + - `packages/3-extensions/cipherstash/test/equality-trait-removal.test.ts` — doc-comment update only. + - `packages/3-extensions/cipherstash/src/execution/operators.ts:39` — doc-comment update only. + - `packages/2-sql/9-family/src/core/query-operations.ts:15` + `:127` — doc-comment updates only (slice 2's lowering-parity notes referencing the now-deleted surfaces). +- `fns.ne` migration sites (grounded by `rg 'fns\.ne\b' packages/ examples/ test/`): + - `packages/2-sql/4-lanes/sql-builder/test/runtime/functions.test.ts` (2 hits) + - `test/integration/test/sql-builder/subquery.test.ts` (1 hit) + - `test/integration/test/sql-builder/where.test.ts` (1 hit) + - `test/integration/test/cli-journeys/invariant-routing.e2e.test.ts` (4 hits) + - `examples/prisma-next-demo/src/queries/cross-author-similarity.ts` (3 hits) diff --git a/projects/unify-query-operations/slices/family-ops-factory/plan.md b/projects/unify-query-operations/slices/family-ops-factory/plan.md new file mode 100644 index 0000000000..96193fba83 --- /dev/null +++ b/projects/unify-query-operations/slices/family-ops-factory/plan.md @@ -0,0 +1,152 @@ +# Slice plan: family-ops-factory + +**Spec.** [`./spec.md`](./spec.md). +**Parent project.** [`projects/unify-query-operations/`](../../). +**Linear.** TML-2354. Per the project plan's amended delivery model (single PR at project close), this slice does NOT open its own PR; its commits land on the project branch `unify-op-registries`. +**Branch.** `unify-op-registries`. +**Base commit (head before slice 2 starts):** `c40aac5ed` (slice 1 D2 — landed slice-1 SATISFIED). + +## Decomposition rationale + +Three dispatches. Slicing piece 1 (factory + type twin) into two dispatches — types first, runtime factory second — is what keeps every dispatch comfortably below the M cap. + +Estimated LoC and file count if piece 1 shipped as a single dispatch: +- `types/operation-types.ts` (new): ~150-200 LoC for the 15 op signatures + trait-constrained codec-id helper types (`EqualityCodecId` / `OrderCodecId` / `TextualCodecId`). +- `core/query-operations.ts` (new): ~200-300 LoC for 15 op impls using `buildOperation`, with TypeScript overloads on `in` / `notIn` (FR16). +- `exports/operation-types.ts` (new): ~5-10 LoC barrel re-export. + +Total: ~350-500 LoC across 3 new files — over the M ceiling (~200 LoC). The natural semantic joint is **types-then-impl**: once the `QueryOperationTypes` type exists, the runtime factory can use `satisfies QueryOperationTypes` to enforce lock-step, and the type twin can be reviewed independently of the lowering choices. + +The remaining work (descriptor-meta wiring + `createExecutionContext` extension + tests) is naturally a single M dispatch carrying ~250-350 LoC, but **splitting the descriptor wiring from the runtime wiring** lets D2 leave the family descriptor self-describing-but-not-yet-wired-into-the-runtime — a clean stable state where the type twin is satisfied by the impls, the descriptor declares its operation-types intent, but the runtime hasn't yet pulled the family into its contributors loop. D3 then flips the switch (contributor extension + tests) and verifies the end-to-end emitter + runtime integration. + +## Dispatches + +### Dispatch 1: Type twin — `QueryOperationTypes` + codec-id helpers + +**Intent.** Ship the type-only twin for the family operations factory at `packages/2-sql/9-family/src/types/operation-types.ts`. Define `QueryOperationTypes` with 15 entries (`eq`, `neq`, `in`, `notIn`, `gt`, `gte`, `lt`, `lte`, `like`, `isNull`, `isNotNull`, `and`, `or`, `exists`, `notExists`) per the slice spec § Approach trait-mapping table. Define trait-constrained codec-id helper types (`EqualityCodecId` / `OrderCodecId` / `TextualCodecId`) that resolve to the union of CT codec ids whose `traits` set includes the relevant trait — same pattern as ADR 203's "How matching works" for `fns.ilike`. Add a small `exports/operation-types.ts` barrel re-export so downstream consumers can `import type { QueryOperationTypes } from '@prisma-next/family-sql/operation-types'`. **What stays the same.** No runtime code yet; no descriptor wiring; the type twin is dead code at the type level until D2's factory references it (`satisfies QueryOperationTypes`). + +**Files in play.** + +- `packages/2-sql/9-family/src/types/operation-types.ts` — NEW. ~150-200 LoC. The 15 type entries + 3 helper types. +- `packages/2-sql/9-family/src/exports/operation-types.ts` — NEW. ~5-10 LoC. Barrel: `export type { QueryOperationTypes } from '../types/operation-types';`. +- `packages/2-sql/9-family/package.json` — MODIFIED. Add the `./operation-types` subpath export (mirroring the existing `./pack`, `./runtime`, `./control` etc. entries). Read the existing `exports` map and follow its format precisely. + +**"Done when" gates.** + +- [ ] `pnpm --filter @prisma-next/family-sql typecheck` — clean. The new types must structurally satisfy ADR 203's expected shape (each `self` is `{ codecId }` / `{ traits }` / no-self per the trait-mapping table; each `impl` has a valid function signature). +- [ ] `pnpm --filter @prisma-next/family-sql build` — clean (must produce the new `dist/operation-types.*` artifacts the subpath export references). +- [ ] 5-package targeted typecheck: `pnpm --filter @prisma-next/operations --filter @prisma-next/sql-contract --filter @prisma-next/sql-orm-client --filter @prisma-next/extension-cipherstash --filter @prisma-next/extension-pgvector typecheck` — clean. The targeted set was orchestrator-accepted in slice 1 as the workspace-typecheck substitute (see `projects/unify-query-operations/reviews/code-review.md § Orchestrator notes`). For slice 2, also include `pnpm --filter @prisma-next/family-sql typecheck` separately. +- [ ] `pnpm lint:deps` — clean (the new file's imports must respect layering; `types/` only depends on `@prisma-next/sql-relational-core/expression` and similar `2-sql/1-core/*` packages). +- [ ] Intent-validation: `git diff --name-only HEAD` shows only the three files named above. No edits to `core/query-operations.ts` (doesn't exist yet; D2's territory), no edits to `core/runtime-descriptor.ts` or `core/control-descriptor.ts` (D2's territory), no edits to `packages/2-sql/5-runtime/` (D3's territory). +- [ ] No-transient-IDs grep over the `+` diff per `agents/implementer.md § No transient project IDs in code`. +- [ ] Edge cases from slice spec covered by this dispatch: "FR17 binary-operator signatures use trait-constrained codec-id generics" (the three helper types embody this); "lock-step between runtime factory and type alias" (the type twin is now authored — D2's factory will `satisfies` against it); "type-level helper naming (OQ4)" — adopted as `EqualityCodecId` / `OrderCodecId` / `TextualCodecId` per spec. + +**Size.** M. Three files; ~150-200 LoC concentrated in one of them; one design judgment (helper-type construction shape); blast radius confined to the family-sql package's published type surface. + +**Model tier.** Sonnet (mid tier). Mechanical extension following ADR 203 (`fns.ilike` pattern) and ADR 206 (`QueryOperationTypes` pattern from pgvector / cipherstash). The implementer derives the helper types by filtering CT keys whose `traits` include the relevant trait — that's one type-level pattern repeated three times. + +**DoR confirmed.** ✓ Spec exists; intent stated; files-in-play named; "done when" binary; size M; failure modes considered (F3 not relevant — discovery is done in the spec; F5 destructive-git standard prohibition); edge cases mapped; affected packages identified (family-sql only); no fixture regen (no IR/emitter/serialiser change); no downstream package adds new public types this dispatch (the operation-types are published but no consumer types-against them until D2's factory satisfies). Naming OQ4 pre-resolved in spec. + +### Dispatch 2: Runtime factory + descriptor-meta wiring + +**Intent.** Ship `sqlFamilyOperations()` at `packages/2-sql/9-family/src/core/query-operations.ts` with all 15 op impls following the pattern at `packages/3-extensions/pgvector/src/core/descriptor-meta.ts:17-58`. Each impl uses `buildOperation` (or the same AST-construction helpers — `BinaryExpr`, `NullCheckExpr`, `AndExpr`, `OrExpr`, `ExistsExpr`, `ListExpression`) and lowers to byte-identical SQL relative to today's `BuiltinFunctions` (sql-builder) / `COMPARISON_METHODS_META` (ORM). The factory's return type uses `satisfies QueryOperationTypes` from D1's type twin (lock-step verification). Extend `sqlRuntimeFamilyDescriptor` in `core/runtime-descriptor.ts` with `codecs: () => []` and `queryOperations: () => sqlFamilyOperations()` so the descriptor satisfies `SqlStaticContributions`. Extend `SqlFamilyDescriptor` in `core/control-descriptor.ts` with `types.queryOperationTypes` pointing at the family's own `operation-types` export (D1's barrel). **What stays the same.** `createExecutionContext` still iterates only `[stack.target, stack.adapter, ...stack.extensionPacks]` — the family's `queryOperations` is registered as a descriptor field but not yet **invoked** by the runtime. The emitter's `extractQueryOperationTypeImports` does already iterate `family` in `allDescriptors`, so the emitted contract WILL pick up the family's `QueryOperationTypes` from this dispatch onward — but no end-to-end emitter test fires until D3 sets up the assertion. + +**Files in play.** + +- `packages/2-sql/9-family/src/core/query-operations.ts` — NEW. ~200-300 LoC. The 15 op impls + `satisfies QueryOperationTypes` lock-step. +- `packages/2-sql/9-family/src/core/runtime-descriptor.ts` — MODIFIED. ~5-10 LoC added. `codecs: () => []` + `queryOperations: () => sqlFamilyOperations()`. The existing `Object.freeze(sqlRuntimeFamilyDescriptor)` call at line 23 must continue to work (the additions are still on a frozen object literal). +- `packages/2-sql/9-family/src/core/control-descriptor.ts` — MODIFIED. ~10-15 LoC added. New `readonly types = { queryOperationTypes: { import: { package: '@prisma-next/family-sql/operation-types', named: 'QueryOperationTypes', alias: 'SqlFamilyQueryOperationTypes' } } }` field on `SqlFamilyDescriptor`. Mirror the typing pattern at `packages/3-extensions/pgvector/src/core/descriptor-meta.ts:97-103`. + +**"Done when" gates.** + +- [ ] `pnpm --filter @prisma-next/family-sql build` — clean. +- [ ] `pnpm --filter @prisma-next/family-sql typecheck` — clean. **Specifically**, the `sqlFamilyOperations()` factory must `satisfies QueryOperationTypes` (lock-step enforced). +- [ ] `pnpm --filter @prisma-next/family-sql test` — green. Existing family-sql tests unchanged; this dispatch adds no new tests (D3's territory). +- [ ] 5-package targeted typecheck (+ family-sql) — clean. +- [ ] `pnpm lint:deps` — clean. The new `core/query-operations.ts` imports from `@prisma-next/sql-relational-core/expression` and `@prisma-next/sql-relational-core/ast` for AST node constructors; no upward dependency violation. +- [ ] **Lowering parity check (manual)** — the implementer compares the family factory's `impl`s against `BuiltinFunctions` impls in `packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts:137-161` and `COMPARISON_METHODS_META` factories at `packages/3-extensions/sql-orm-client/src/types.ts:289-378` to confirm AST output parity. Document the per-op comparison in the implementer report (e.g. "family `eq` builds `BinaryExpr.eq(...)` — same shape as `COMPARISON_METHODS_META.eq.create` line N"). +- [ ] Intent-validation: `git diff --name-only HEAD` shows only the three files named above. No edits to `packages/2-sql/5-runtime/src/sql-context.ts` (D3's territory). No edits to ORM model accessor, sql-builder runtime, `COMPARISON_METHODS_META`, or `BuiltinFunctions`. +- [ ] No-transient-IDs grep. +- [ ] Edge cases from slice spec covered by this dispatch: "in/notIn TypeScript overloads (FR16)" (the impls + their type signatures carry the overloads); "and/or/exists/notExists with no self" (registered with no `self` field; impl uses `AndExpr.of` / `OrExpr.of` / `ExistsExpr.exists` / `ExistsExpr.notExists`); "isNull/isNotNull using D1's any:true arm" (impls use `NullCheckExpr.isNull` / `NullCheckExpr.isNotNull`; `self: { any: true }` declared); "lock-step between runtime factory and type alias" (the `satisfies` constraint enforces it at compile time); "family currently has no descriptorMeta slot — adding types.queryOperationTypes is new surface" (the control-descriptor extension lands here, additive); "family runtime descriptor needs codecs() returning []" (added as part of the `SqlStaticContributions` satisfaction). + +**Size.** M. Three files; ~250-350 LoC; one design judgment (lowering shape parity, but the implementer has direct references to copy from); blast radius confined to family-sql. + +**Model tier.** Opus (orchestrator tier). 15 operations is enough volume that the implementer benefits from careful reasoning about lowering parity, overload signatures (`in`/`notIn`), and `satisfies QueryOperationTypes` constraint resolution. The cost is worth it because a lowering mistake on any op would surface as a slice-3 test failure or — worse — a silent SQL-emission regression. + +**DoR confirmed.** ✓ Spec exists; D1 (type twin) must be SATISFIED first (dependency); intent stated; files-in-play named; "done when" binary including the manual lowering-parity check; size M; failure modes considered (F2 — no optional-field magic in impls; F3 — discovery already done; F5 — destructive-git prohibition); edge cases mapped (six from slice spec covered here); affected package = family-sql; no fixture regen; no downstream package adds new public types beyond what D1 already published; lowering parity (OQ3) — working position is "copy AST node choices from BuiltinFunctions/COMPARISON_METHODS_META verbatim." + +### Dispatch 3: `createExecutionContext` contributor extension + integration tests + +**Intent.** Extend `SqlExecutionStack` at `packages/2-sql/5-runtime/src/sql-context.ts:124-128` with `readonly family: SqlRuntimeFamilyDescriptor` (referencing the type that's part of `@prisma-next/family-sql`). Extend `createSqlExecutionStack` to accept an optional `family?` input and default it to `sqlRuntimeFamilyDescriptor` (importing from the family package). Extend `createExecutionContext`'s contributors array at lines 766-770 to `[stack.family, stack.target, stack.adapter, ...stack.extensionPacks]` — the family becomes the first contributor so the family's codec contribution (empty) and queryOperations are visited first; this is documentational, not functional (the ordering doesn't matter at runtime — codec uniqueness and operation registration are commutative). Add a new test file `packages/2-sql/9-family/test/query-operations.test.ts` covering: (a) direct registry probe — `context.queryOperations.entries()` contains all 15 family op names; (b) trait-gated per-codec index — `eq` indexes under `pg/int4@1` (declares `equality`) but NOT under cipherstash's `cipherstash/string@1` (declares no traits); (c) `any: true` per-codec index — `isNull` indexes under EVERY codec including cipherstash; (d) no-self ops not surfacing — `and` is in the registry but not on any codec's per-column index; (e) emitter integration — emit a fixture contract for a stack with default `sqlRuntimeFamilyDescriptor` + postgres adapter and assert the generated `contract.d.ts` `QueryOperationTypes` alias is the intersection of `SqlFamilyQueryOperationTypes` and `PgAdapterQueryOps`. **What stays the same.** No edit to `COMPARISON_METHODS_META`, `BuiltinFunctions`, the ORM model accessor, the sql-builder `fns` proxy. The legacy surfaces remain primary; the family entries are inert backups. Every existing test in the workspace continues to pass without modification. + +**Files in play.** + +- `packages/2-sql/5-runtime/src/sql-context.ts` — MODIFIED. ~10-20 LoC. (1) Extend `SqlExecutionStack` (line 124-128) with `readonly family: SqlRuntimeFamilyDescriptor`; (2) extend `createSqlExecutionStack` (line 166-180) with `family?` optional input defaulting to `sqlRuntimeFamilyDescriptor`; (3) extend the contributors array at line 766-770. Import the family descriptor from `@prisma-next/family-sql/runtime`. +- `packages/2-sql/9-family/test/query-operations.test.ts` — NEW. ~200-300 LoC. Five test groups per the intent above. + +**"Done when" gates.** + +- [ ] `pnpm --filter @prisma-next/family-sql build` — clean. +- [ ] `pnpm --filter @prisma-next/sql-runtime build` — clean. **New** check this dispatch — D3 is the first dispatch touching `@prisma-next/sql-runtime` in this slice. +- [ ] `pnpm typecheck` for the 5-package targeted set + `@prisma-next/sql-runtime` + `@prisma-next/family-sql` — clean. The `SqlExecutionStack` extension widens the type; existing `createSqlExecutionStack` callers (~8 sites — `postgres.ts`, `sqlite.ts`, test helpers, `runtime-helpers.ts`, etc.) continue to typecheck because `family` is optional. +- [ ] `pnpm --filter @prisma-next/family-sql test` — green. The new test file passes all five test groups. +- [ ] **Workspace-wide test suite (sql-orm-client + sql-runtime + postgres + sqlite extensions + cipherstash + pgvector)** — green. This is the SDoD9 ("no regression") verification. The full set: `pnpm --filter @prisma-next/sql-runtime --filter @prisma-next/sql-orm-client --filter @prisma-next/extension-postgres --filter @prisma-next/extension-sqlite --filter @prisma-next/extension-cipherstash --filter @prisma-next/extension-pgvector test`. Every test must pass without modification. +- [ ] `pnpm lint:deps` — clean. `sql-runtime` now imports from `@prisma-next/family-sql/runtime` (the default family descriptor); confirm this is an allowed layering relationship per `architecture.config.json`. +- [ ] **F3 verification grep** — `rg 'createSqlExecutionStack\(' packages/` returns the same set of call sites as the slice spec named (~8 sites). If a new call site appeared (e.g. a new test added in slice 1 or between dispatches), inspect it to confirm it still compiles with `family?` optional. Document the count in the implementer report. +- [ ] Intent-validation: `git diff --name-only HEAD` shows only the two files named above. No edits to D1/D2 files (`types/operation-types.ts`, `core/query-operations.ts`, `core/runtime-descriptor.ts`, `core/control-descriptor.ts` — frozen after D2). No edits to ORM model accessor, sql-builder runtime, `COMPARISON_METHODS_META`, `BuiltinFunctions`. +- [ ] No-transient-IDs grep. +- [ ] Edge cases from slice spec covered by this dispatch: "registering all 15 ops without collision against existing extension ops" (the registry test asserts the 15 entries exist alongside cipherstash + pgvector entries with no collision); "trait expansion cost" (the per-codec index test exercises the trait expansion path explicitly); "emitter alias-aggregation picking up family operationTypes" (the emitter integration test); "inert-backup state" (the workspace-wide test pass asserts no behavior change to existing consumers); "cipherstash columns with traits:[] must NOT gain family ops they don't declare traits for" (the per-codec index test asserts this for cipherstash); "createSqlExecutionStack callers continue to work without family arg" (the F3 grep + workspace test pass jointly verify); "workspace typecheck substitution carries from slice 1" (the 5-package targeted set + family-sql + sql-runtime is the substitute); "implementer might be tempted to delete legacy surfaces" (intent-validation gate catches it). + +**Size.** M. Two files; ~200-300 LoC concentrated in the test file; one design touchpoint (the default-family wiring); blast radius reaches `@prisma-next/sql-runtime` for the first time in this slice, so the cross-package typecheck and workspace test pass are load-bearing gates. + +**Model tier.** Sonnet (mid tier). The sql-context.ts changes are small and pattern-following (extending an existing array, adding a default-typed field). The tests are mechanical assertions against a registry the implementer has already built (D2). Opus-tier reasoning isn't needed. + +**DoR confirmed.** ✓ Spec exists; D2 (factory + descriptor wiring) must be SATISFIED first (dependency — D3's tests assert against D2's factory output, and the contributors array references the family's queryOperations slot that D2 added); intent stated; files-in-play named; "done when" binary including the workspace-wide regression check; size M; failure modes considered (F3 — re-grep createSqlExecutionStack call sites to confirm no surprises since slice 1; F5 — destructive-git prohibition); edge cases mapped (eight from slice spec covered here); affected packages = sql-runtime + family-sql; downstream `pnpm typecheck` is workspace-wide via the 5-pkg substitute + family-sql + sql-runtime; cross-package gate satisfied (sql-runtime is a public-export package consumed by every adapter / extension that calls createSqlExecutionStack). + +## Dependencies between dispatches + +Sequential stack: D1 → D2 → D3. Dependencies: + +- D2 depends on D1: D2's factory uses `satisfies QueryOperationTypes` which requires the type twin to exist. +- D3 depends on D2: D3's tests assert against `context.queryOperations.entries()` which only contains family ops after D2's `runtime-descriptor.ts` extension AND D3's contributors-array extension fire. D3's emitter integration test also asserts `Contract['queryOperationTypes']` contains `SqlFamilyQueryOperationTypes` — that depends on D2's `control-descriptor.ts` extension exposing the `types.queryOperationTypes` slot. + +No parallelization opportunity within this slice. + +## Cross-references + +### Failure modes threaded + +- [F2 — Constructor magic for optional fields](../../../../drive/calibration/failure-modes.md#f2-constructor-magic-for-optional-fields). Not directly relevant in this slice (no constructor on the surfaces touched). Worth noting because the `family?: SqlRuntimeFamilyDescriptor` optional with a default in `createSqlExecutionStack` is a related shape — but it's the right shape here (default is correct because there's only one SQL family today; future polymorphism YAGNI per spec OQ1). +- [F3 — Discovery via test suite instead of grep](../../../../drive/calibration/failure-modes.md#f3-discovery-via-test-suite-instead-of-grep). D2 implementer threads this for "discover which AST node constructors `BuiltinFunctions` uses for each op" — use `rg` on the BuiltinFunctions impls, not test-the-emitted-SQL-iteratively. D3 implementer threads this for "discover which `createSqlExecutionStack` callers exist" — re-grep against the slice-spec snapshot. +- [F5 — Destructive git operations](../../../../drive/calibration/failure-modes.md#f5-destructive-git-operations-executed-by-subagents-without-orchestrator-approval). Standard non-negotiable disposition in all three dispatches. + +### Grep library entries + +- `rg ': any\b|\bany\[\]'` — forbidden TypeScript-`any` check. The slice's `any: true` field name is not the TypeScript-`any` type; the lint must distinguish. The existing operations registry test in slice 1 uses property-literal `any: true` and passes the lint, confirming the distinction holds. +- `rg 'createSqlExecutionStack\(' packages/` — D3 gate to verify the call-site set is the same as the slice-spec snapshot (~8 sites). +- `rg 'satisfies QueryOperationTypes' packages/2-sql/9-family/` — D2 verification that the lock-step constraint is present (a missing `satisfies` would allow the runtime factory to drift from the type twin). +- Pre-flight grep for D2: `rg 'BinaryExpr|NullCheckExpr|AndExpr|OrExpr|ExistsExpr|ListExpression' packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts packages/3-extensions/sql-orm-client/src/types.ts` — surfaces the AST node imports the family factory will need. + +## Slice-DoD reachability + +Every condition in the slice-DoD is covered by one or more dispatches: + +| Slice-DoD condition | Covered by | +|---|---| +| **SDoD1.** All gates pass (build, test, typecheck, lint:deps, intent-validation). | All three dispatches contribute; final pass on D3. | +| **SDoD2.** Every pre-named edge case handled per its disposition. | Distributed per the edge-cases-covered tables in each dispatch. | +| **SDoD3.** Reviewer verdict accept. | D3's reviewer round is the slice-level verdict. | +| **SDoD4.** Manual-QA N/A (declared in spec). | Pre-accepted; no per-dispatch verification needed. | +| **SDoD5.** No out-of-scope touches. | Intent-validation gate in each dispatch. | +| **SDoD6.** `Contract['queryOperationTypes']` includes family's 15 op names at the type level. | D2 establishes the descriptor slot; D3's emitter integration test asserts it. | +| **SDoD7.** `context.queryOperations.entries()` contains the 15 family op names at runtime. | D3's direct registry probe test. | +| **SDoD8.** Trait gating verified at registry-assembly time. | D3's per-codec index tests (trait-gated, `any: true`, no-self). | +| **SDoD9.** No regression in existing tests. | D3's workspace-wide test run. | + +## Risks + +1. **D2 lowering parity drift.** The family factory's `impl`s must produce byte-identical AST nodes to today's `BuiltinFunctions` and `COMPARISON_METHODS_META` for slice 3's deletion to be a no-op on emitted SQL. The D2 "done when" gate includes a **manual lowering-parity check** (the implementer reports per-op AST shape comparison). If the implementer drifts an impl (e.g. uses a different `BinaryExpr` constructor variant than today's), slice 3 will surface byte-different SQL emission and require rework. Mitigation: spec OQ3 documents the working position ("copy AST node choices verbatim"); the slice plan's D2 brief restates it. Risk contained but real. +2. **D3 `lint:deps` failure on the new `sql-runtime` → `family-sql/runtime` import.** `@prisma-next/sql-runtime` imports from `@prisma-next/family-sql/runtime` for the first time in D3. Layering in `architecture.config.json` may forbid this (runtime → family direction). If `lint:deps` flags it, the wiring shape needs revisiting — possible alternative: invert the dependency by having the family pack consume `createExecutionContext` rather than the other way around (but that changes the architectural shape more fundamentally). Mitigation: pre-flight check `architecture.config.json` for `sql-runtime` allowed dependencies; if `family-sql` isn't there, surface as a stop-condition for orchestrator decision (likely outcome: amend `architecture.config.json` to allow it, given the family is the layer above sql-runtime in the project's mental model). +3. **D3 workspace-wide regression check expense.** Running the full workspace test suite is expensive (multi-minute). The D3 implementer should run it **once** at end-of-round per `agents/implementer.md § Test execution discipline`, not iteratively. WIP inspection cadence: a single ~5-10 min test-suite run is acceptable for D3's wall-clock budget. +4. **Family descriptor's optional `family?` default — silently masking missing-family bugs.** If a future call site forgets to pass `family` (or passes `undefined`), the default `sqlRuntimeFamilyDescriptor` activates silently. For slice 2 this is by design (the call-site fanout cost would dominate the slice's LoC). Mitigation: document the default explicitly in `createSqlExecutionStack`'s JSDoc (D3 implementer adds it); slice 5's close-out ADR records the YAGNI rationale. +5. **The 5-package targeted typecheck might not catch all consumers.** Slice 1 substituted the workspace typecheck with a 5-package set; slice 2 expands it (+ family-sql + sql-runtime). The remaining cli / postgres / family-sql cascaded failures are still pre-existing and orthogonal. If slice 2's changes accidentally regress cli or postgres typechecks, the substitution would miss it. Mitigation: D3 implementer attempts a workspace-wide typecheck once and reports the failure mode — if the failures are the same as slice 1's documented set, the substitution holds; if any new failures appear, halt and surface to the orchestrator. diff --git a/projects/unify-query-operations/slices/family-ops-factory/spec.md b/projects/unify-query-operations/slices/family-ops-factory/spec.md new file mode 100644 index 0000000000..c657c9581d --- /dev/null +++ b/projects/unify-query-operations/slices/family-ops-factory/spec.md @@ -0,0 +1,191 @@ +# Slice: family-ops-factory + +_Parent project: [`projects/unify-query-operations/`](../../). This slice satisfies FR1-FR4, FR15-FR17, AC2/AC5/AC6/AC11/AC12 from the project spec, and partial AC4 (the per-column ORM surface is unchanged because the consumer doesn't read the new entries yet)._ + +## At a glance + +Ship `sqlFamilyOperations()` in `@prisma-next/family-sql` covering all 15 family operations (`eq`, `neq`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`, `like`, `isNull`, `isNotNull`, `and`, `or`, `exists`, `notExists`) as TypeScript-function operation descriptors per [ADR 206](../../../../docs/architecture%20docs/adrs/ADR%20206%20-%20Operations%20as%20TypeScript%20functions.md). Wire the family as a contributor to `createExecutionContext` so the registry receives the family's operations alongside target/adapter/extension contributions. Add the family's `descriptorMeta.types.queryOperationTypes` slot so the emitter aggregates the family's `QueryOperationTypes` alias into the generated `Contract['queryOperationTypes']`. + +**End-of-slice state**: the registry carries the 15 family operations (trait-targeted where applicable, `any: true` for `isNull` / `isNotNull` using D1's arm, no `self` for `and` / `or` / `exists` / `notExists`) and `Contract['queryOperationTypes']` includes them — **but no consumer reads them yet**. `COMPARISON_METHODS_META` (ORM) and `BuiltinFunctions` (sql-builder) still take precedence in both authoring surfaces. The family entries are inert backups until slice 3 (`collapse-consumers`) deletes the legacy surfaces and rewires the consumers. + +## Scope + +### In scope + +- **New file:** `packages/2-sql/9-family/src/types/operation-types.ts` — type-only `QueryOperationTypes` mirroring the runtime factory. Per ADR 206's "operations as TypeScript functions" pattern, the type carries the user-facing signatures (codec-id generics constrained to the relevant trait's codec-id union per FR17). +- **New file:** `packages/2-sql/9-family/src/core/query-operations.ts` — `sqlFamilyOperations()` runtime factory that returns the 15 operation descriptors. Each descriptor's `impl` uses `buildOperation` (same pattern as `pgvector/src/core/descriptor-meta.ts:17-58`). +- **New file:** `packages/2-sql/9-family/src/exports/operation-types.ts` — re-exports the `QueryOperationTypes` type so the emitter can import it via the `descriptorMeta.types.queryOperationTypes` slot (matching the `pgvector` / `cipherstash` / `paradedb` / `postgis` pattern). +- **Modified:** `packages/2-sql/9-family/src/core/runtime-descriptor.ts` — extend `sqlRuntimeFamilyDescriptor` to satisfy `SqlStaticContributions` (gain `codecs: () => []` since family owns no codecs, and `queryOperations: () => sqlFamilyOperations()`). +- **Modified:** `packages/2-sql/9-family/src/core/control-descriptor.ts` — extend `SqlFamilyDescriptor` to expose `types.queryOperationTypes` per the pattern at `packages/3-extensions/pgvector/src/core/descriptor-meta.ts:97-103`. The import points at the family's own `@prisma-next/family-sql/operation-types` export. +- **Modified:** `packages/2-sql/9-family/src/exports/pack.ts` and/or `package.json` exports map — add the operation-types export. +- **Modified:** `packages/2-sql/5-runtime/src/sql-context.ts` — extend `SqlExecutionStack` with an optional `family?: SqlRuntimeFamilyDescriptor` field (default `sqlRuntimeFamilyDescriptor`); extend `createSqlExecutionStack` to set the default; extend `createExecutionContext`'s contributors array (`sql-context.ts:766-770`) to put the family first: `[stack.family, stack.target, stack.adapter, ...stack.extensionPacks]`. +- **Modified or new test file(s):** `packages/2-sql/9-family/test/query-operations.test.ts` (new) — assert the factory registers all 15 ops with the correct `self` shapes (`equality` traits, `order` traits, `textual` traits, `any: true` for null checks, no `self` for boolean composition), exercises an end-to-end registration through a synthetic `createExecutionContext` call, and confirms cipherstash-style trait-empty codecs do NOT pick up trait-gated family ops (the `any: true` ops still surface). +- **Possibly modified:** the family-sql `package.json` to wire the new operation-types subpath export. + +### Out of scope (this slice) + +- **Deletion of `COMPARISON_METHODS_META`** (`packages/3-extensions/sql-orm-client/src/types.ts:309+`) and **deletion of `BuiltinFunctions` / `createBuiltinFunctions`** (sql-builder). Slice 3's territory. +- **Rewiring the ORM model accessor's two-loop synthesis to a single registry loop** (`packages/3-extensions/sql-orm-client/src/model-accessor.ts:71-167`). Slice 3. +- **Rewiring the sql-builder `fns` Proxy to read only from the registry** (`packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts:180-195`). Slice 3. +- **Renaming `fns.ne` → `fns.neq`** at sql-builder call sites (the family uses `neq` to match `COMPARISON_METHODS_META`; current sql-builder uses `ne`). The migration happens in slice 3 when the legacy `BuiltinFunctions['ne']` entry is deleted; slice 2 leaves both `neq` (in the registry, inert) and `ne` (in `BuiltinFunctions`, still active) coexisting. +- **HAVING surface derivation** (`HavingComparisonMethods` deletion). Slice 4. +- **Aggregate-only functions** (`count`, `sum`, `avg`, `min`, `max`) — project non-goal. +- **ORM ordering registry / `asc` / `desc`** — slice 3 introduces the private ORM ordering registry; this slice ships only the SQL family registry. +- **ADR drafting** — defers to slice 5's close-out ADR. + +## Approach + +The slice ships three logical pieces of work that compose into one PR's diff: **(1) the factory and its type-level twin**, **(2) descriptor-meta and runtime-descriptor wiring**, **(3) `createExecutionContext` contributor extension**. The pieces are sequenced but mutually dependent for the end-of-slice promise (the emitter-generated `Contract['queryOperationTypes']` and the registry both carry the 15 family operations). + +**(1) Factory + type twin.** `sqlFamilyOperations()` is structured like `pgvectorQueryOperations()` at `packages/3-extensions/pgvector/src/core/descriptor-meta.ts:17-58`: a function generic over `CT extends CodecTypesBase`, returning an object literal whose keys are the operation names and whose values are descriptors with `self` and `impl`. The runtime `impl`s use `buildOperation` from `@prisma-next/sql-relational-core/expression` to build the lowered AST nodes. The 15 ops break down per the trait-mapping table from the project spec § Approach (illustrative — the implementer authors the exact impls, lowering templates may need to draw from `BuiltinFunctions` and `COMPARISON_METHODS_META` for parity): + +```ts +// Illustrative — keys, self shapes, and one impl pattern. Full lowering +// templates / sub-AST node choices are the implementer's call, drawing +// from BuiltinFunctions (sql-builder) and COMPARISON_METHODS_META (ORM) +// to preserve byte-identical emitted SQL. +export function sqlFamilyOperations(): QueryOperationTypes { + return { + // Equality predicates — trait-gated + eq: { self: { traits: ['equality'] }, impl: /* binary BinaryExpr op='eq' */ }, + neq: { self: { traits: ['equality'] }, impl: /* binary BinaryExpr op='neq' */ }, + in: { self: { traits: ['equality'] }, impl: /* list BinaryExpr op='in' (with TS overloads per FR16) */ }, + notIn: { self: { traits: ['equality'] }, impl: /* list BinaryExpr op='notIn' */ }, + + // Order predicates — trait-gated + gt: { self: { traits: ['order'] }, impl: /* binary BinaryExpr op='gt' */ }, + gte: { self: { traits: ['order'] }, impl: /* binary BinaryExpr op='gte' */ }, + lt: { self: { traits: ['order'] }, impl: /* binary BinaryExpr op='lt' */ }, + lte: { self: { traits: ['order'] }, impl: /* binary BinaryExpr op='lte' */ }, + + // Textual predicate — trait-gated + like: { self: { traits: ['textual'] }, impl: /* BinaryExpr or LikeExpr */ }, + + // Null checks — use D1's `any: true` arm + isNull: { self: { any: true }, impl: /* NullCheckExpr.isNull */ }, + isNotNull: { self: { any: true }, impl: /* NullCheckExpr.isNotNull */ }, + + // Boolean composition — no self (sql-builder-only; not a column method) + and: { impl: /* AndExpr.of */ }, + or: { impl: /* OrExpr.of */ }, + exists: { impl: /* ExistsExpr.exists */ }, + notExists: { impl: /* ExistsExpr.notExists */ }, + }; +} +``` + +The matching `QueryOperationTypes` type in `types/operation-types.ts` is the type-only twin (per ADR 206's pattern at `packages/3-extensions/pgvector/src/types/operation-types.ts`). The binary trait-gated operators' user-facing signatures follow FR17's trait-constrained codec-id generic pattern from ADR 203: + +```ts +// Illustrative — final helper names are the implementer's choice. The +// implementer derives `EqualityCodecId` / `OrderCodecId` / +// `TextualCodecId` from CT by filtering codec ids whose trait sets +// include the relevant trait. This is the same pattern ADR 203's +// "How matching works" section describes for `fns.ilike`. +readonly eq: { + readonly self: { readonly traits: readonly ['equality'] }; + readonly impl: >( + a: CodecExpression | null, + b: CodecExpression | null, + ) => Expression<{ codecId: 'pg/bool@1'; nullable: false }>; +}; +``` + +**(2) Descriptor-meta wiring (control + runtime).** The runtime descriptor (`sqlRuntimeFamilyDescriptor`) currently has only `kind`, `id`, `familyId`, `version`, `create()`. It does not satisfy `SqlStaticContributions` (which the runtime contributors loop expects). The slice adds two slots: + +```ts +// Illustrative — applied to the existing object literal in runtime-descriptor.ts. +codecs: () => [], // family owns no codecs; targets and adapters do +queryOperations: () => sqlFamilyOperations(), +``` + +The control descriptor (`SqlFamilyDescriptor` class in `core/control-descriptor.ts`) gains `types.queryOperationTypes`: + +```ts +// Illustrative — placed on the class instance. +readonly types = { + queryOperationTypes: { + import: { + package: '@prisma-next/family-sql/operation-types', + named: 'QueryOperationTypes', + alias: 'SqlFamilyQueryOperationTypes', + }, + }, +} as const; +``` + +The emitter's `extractQueryOperationTypeImports` (`packages/1-framework/1-core/framework-components/src/control/control-stack.ts:111-124`) already iterates `allDescriptors` which **already includes `family`** (line 353: `const allDescriptors = [family, target, ...]`). So once the family's control descriptor exposes the `types.queryOperationTypes` slot, the alias-aggregation lifts it into the generated contract's `QueryOperationTypes` alias with zero emitter-side code changes. + +**(3) `createExecutionContext` contributor wiring.** The contributors array at `packages/2-sql/5-runtime/src/sql-context.ts:766-770` is today `[stack.target, stack.adapter, ...stack.extensionPacks]`. The slice extends it to `[stack.family, stack.target, stack.adapter, ...stack.extensionPacks]`. To minimize fanout across the ~8 existing `createSqlExecutionStack` call sites, the `family` field on `SqlExecutionStack` defaults to `sqlRuntimeFamilyDescriptor` inside `createSqlExecutionStack` (a `family?` optional input). Existing call sites continue to work unchanged; future polymorphism (hypothetical other SQL family flavours) can pass the family explicitly. + +**End-of-slice integration test.** A new test in `packages/2-sql/9-family/test/query-operations.test.ts` builds a minimal `createExecutionContext` with a stack carrying a synthetic codec set, asserts that `context.queryOperations.entries()` contains all 15 family operation names, and asserts the trait-expansion mapping (e.g. `eq` indexes under all codecs declaring `equality`; `isNull` indexes under every codec via the `any: true` arm; `and` is registered with no self and is therefore invisible to the ORM but visible on the registry). + +## Edge cases (Example-Mapping) + +| Edge case | Disposition | Notes | +|---|---|---| +| Registering all 15 ops without collision against existing extension ops (cipherstash / pgvector) | Handle | Cipherstash names its ops with the `cipherstash` prefix (`cipherstashEq`, etc.); pgvector with semantic names (`cosineDistance`, `cosineSimilarity`). No collisions today. The validator at `createSqlOperationRegistry` throws on duplicate names, so a future extension collision becomes a load-time error — the project spec FR12 already names this as the policy. | +| `isNull` / `isNotNull` using D1's `any: true` arm | Handle | Both ops declare `self: { any: true }`. Test asserts the registry's per-codec index carries these for every codec descriptor, demonstrating D1's foundation flows through. | +| Trait expansion cost (NFR1) at registry assembly | Handle | The runtime contributors loop iterates each contributor's `queryOperations()` once at `createExecutionContext` construction. Adding 15 entries (of which ~9 trait-targeted expand × N codecs) is bounded and runs once per context. NFR1 asks for "no measurable regression on existing benchmarks" — the slice's tests measure this in a follow-up dispatch if needed; first pass is "registration completes synchronously without observable cost." | +| Emitter alias-aggregation picking up family operationTypes | Handle | `extractQueryOperationTypeImports` (`control-stack.ts:111-124`) already iterates `allDescriptors` which includes `family`. The slice extends the family control descriptor's `types.queryOperationTypes` slot. Test: emit a contract from a stack with default `sqlRuntimeFamilyDescriptor` + a postgres adapter; assert the generated `contract.d.ts` `QueryOperationTypes` alias is the intersection `SqlFamilyQueryOperationTypes & PgAdapterQueryOps` (or similar). | +| Inert-backup state — `COMPARISON_METHODS_META` and `BuiltinFunctions` still take precedence | Handle | The slice deliberately does NOT delete the legacy surfaces. The end-state assertion: a `model.field.eq(...)` ORM call still routes through `COMPARISON_METHODS_META` (slice 3 changes this); a `fns.eq(...)` sql-builder call still routes through `BuiltinFunctions` (slice 3 changes this). Slice 2's tests therefore probe `context.queryOperations.entries()` directly, not the consumer surfaces. The test for the no-regression promise (existing call sites' behaviour unchanged) is the unmodified pass of every prior ORM/sql-builder test in the workspace. | +| `in` / `notIn` TypeScript overloads (FR16) | Handle | ADR 206 explicitly permits TypeScript overloads in operation `impl` types. The family's `in` / `notIn` carry two overloads matching today's `BuiltinFunctions`: `(expr, values: readonly unknown[])` and `(expr, subquery: Subquery<...>)`. The runtime impl branches on the second arg's shape (same pattern as `inOrNotIn` in `sql-builder/runtime/functions.ts:153-160`). | +| `and` / `or` / `exists` / `notExists` with no `self` — sql-builder-only entries | Handle | These four ops are registered with no `self` field. The ORM model accessor's resolution loop (`packages/3-extensions/sql-orm-client/src/model-accessor.ts:71-85`) explicitly skips ops where `self === undefined` (line 74: `if (!self) continue;`) — so they never surface as column methods. The sql-builder's `fns` proxy will reach them in slice 3 when `Functions` drops the `BuiltinFunctions &` intersection. For slice 2 they sit in the registry inert. | +| Cipherstash columns with `traits: []` must NOT gain family ops they don't declare traits for | Handle | The registry-assembly trait-expansion (D1 prepared this) iterates each codec descriptor and only indexes an op under a codec when the codec's `traits` set includes every required trait. Cipherstash's `traits: []` therefore matches **none** of `equality` / `order` / `textual` — `eq` / `neq` / `gt` / `like` / etc. do NOT index under cipherstash codecs. The `any: true` ops (`isNull` / `isNotNull`) DO index under cipherstash codecs. Test: assert this directly in the family-sql test. (Slice 1 AC3 lands fully in slice 3 when `fns.eq(cipherstashCol, cipherstashCol)` fails type-checking on the sql-builder surface.) | +| Workspace `pnpm typecheck` substitution carries from slice 1 | Handle | The pre-existing typecheck failure in `@prisma-next/cli` / `@prisma-next/postgres` / `@prisma-next/family-sql` is orchestrator-accepted as orthogonal. Slice 2 expands the 5-package targeted typecheck to include any new package(s) whose types consume the family's `QueryOperationTypes` — at minimum `@prisma-next/family-sql` itself (currently in the failing set, but only via cascade from cli; family-sql's own typecheck should pass and remain green for slice 2). The slice plan must verify `@prisma-next/family-sql typecheck` is green pre-slice and stays green. | +| The family currently has no `descriptorMeta` slot — adding `types.queryOperationTypes` is new surface | Handle | The family's control descriptor today carries `emission` and `authoring`. Adding `types.queryOperationTypes` is purely additive — no consumer of the family descriptor pattern-matches on `types` being absent. The control-stack's `extractQueryOperationTypeImports` already guards on `descriptor.types?.queryOperationTypes` (line 117) — undefined-safe. | +| `createSqlExecutionStack` callers continue to work without family arg | Handle | `family?: SqlRuntimeFamilyDescriptor` is optional with default `sqlRuntimeFamilyDescriptor`. All ~8 existing call sites (`postgres.ts`, `sqlite.ts`, `sql-orm-client/test/helpers.ts`, etc.) continue to work unmodified. The test that proves this: existing test suites pass without modification (the workspace's `pnpm test:packages` is the bar). | +| Family runtime descriptor needs `codecs()` returning `[]` | Handle | `SqlStaticContributions` requires `codecs: () => readonly[]`. The family owns no codecs (targets and adapters own them). The empty-array contribution is structurally correct and exercises the `collectCodecDescriptors` loop's empty-input handling, which has no special branch (existing `for (const descriptor of contributor.codecs()) { ... }` simply skips empty contributions). | +| `ne` vs `neq` naming carry-over for slice 3 | Defer (named explicitly in § Out of scope) | The family uses `neq` (matching `COMPARISON_METHODS_META`). `BuiltinFunctions['ne']` continues to exist until slice 3. Slice 3 deletes `BuiltinFunctions` and migrates any sql-builder consumer that uses `fns.ne(...)` to `fns.neq(...)`. Slice 2's spec records this for slice 3's plan to thread. | +| Family operations name choice: `neq` vs `ne` | Handle | Chosen: `neq`. Reason: `COMPARISON_METHODS_META` uses `neq`; renaming `fns.ne` callers in slice 3 is fewer surfaces to touch than renaming every `column.neq` ORM caller. The project spec § Approach trait-mapping table uses `neq` (FR1 is consistent). The project plan slice 2 description used `ne` — treating that as the slice author's shorthand, overridden here by the consistent project spec wording. | +| Lock-step between SQL family's runtime factory and the contract's emitted type alias | Handle | The runtime factory's return type IS `QueryOperationTypes` (the type-only twin in `types/operation-types.ts`). The `satisfies QueryOperationTypes` constraint on the factory body keeps them structurally in lock-step at compile time. If the implementer drifts the runtime away from the type-level shape, the family-sql package's typecheck fails. | +| Implementer might be tempted to delete legacy surfaces in this slice | Explicitly out | Deletion is slice 3's territory. The slice spec's `## Out of scope` enumerates the legacy surfaces explicitly. The intent-validation gate in the slice plan must catch any diff that touches `model-accessor.ts`, `sql-orm-client/src/types.ts:309+`, or `sql-builder/runtime/functions.ts`. | + +## Contract impact + +**Affected contract-surface types.** The contract's `queryOperationTypes` slot today carries adapter + extension operation types only (via `descriptorMeta.types.queryOperationTypes` on each contributor). After slice 2, the family also contributes — its `QueryOperationTypes` alias intersects into the generated contract's `QueryOperationTypes` alongside the existing contributions. Concretely, a `contract.d.ts` for a stack with the SQL family + postgres adapter will see `QueryOperationTypes = SqlFamilyQueryOperationTypes & PgAdapterQueryOps` (alias names are the implementer's call). + +**Migration plan for downstream consumers.** Purely additive. No downstream consumer is forced to use the new entries; the registry surfaces them but the ORM model accessor and sql-builder `fns` proxy both still take their primary signal from `COMPARISON_METHODS_META` / `BuiltinFunctions` respectively. Slice 3 is the slice that activates the new entries by deleting the legacy surfaces. Cipherstash and pgvector extensions continue to register their own ops; no name collision with the 15 family names. + +**Verification.** A snapshot test in the family-sql package emits a fixture contract from a known stack (default family + postgres adapter + no extensions) and asserts the generated `contract.d.ts` includes the family's `QueryOperationTypes` import + intersection. A round-trip test (validate the emitted contract, build an `ExecutionContext`, assert `context.queryOperations.entries()` contains all 15 family ops) confirms the chain end-to-end. + +## Adapter impact + +**Low — but worth a sniff test.** No adapter code is touched directly. The family's `descriptorMeta.types.queryOperationTypes` flows through the **same** alias-aggregation logic at `extractQueryOperationTypeImports` (`control-stack.ts:111-124`) that adapters use today. The function reads `descriptor.types?.queryOperationTypes` — identical handling for the family and adapters. Verify by emitting a contract for a stack that includes both family contribution and the postgres adapter's contribution; the resulting `QueryOperationTypes` alias should be a clean intersection of both (no precedence policy needed; the alias-aggregation step simply intersects all contributors' types). + +## ADR pointer + +Defers to slice 5's close-out ADR ("ADR NNN — Unified SQL-family operation registry"), per the project plan slice 5. This slice does not draft a separate ADR; the architectural shift is recorded in the close-out ADR alongside slices 1, 3, and 4. + +## Slice Definition of Done + +- [ ] **SDoD1.** All "Done when" gates from the slice plan pass: `pnpm --filter @prisma-next/family-sql build` clean; `pnpm --filter @prisma-next/family-sql test` green (new tests included); the 5-package targeted typecheck (or extended to include family-sql) is green; `pnpm lint:deps` clean; intent-validation confirms diff matches slice scope (no edits to ORM model accessor, sql-builder `fns` proxy, or `COMPARISON_METHODS_META` / `BuiltinFunctions`). +- [ ] **SDoD2.** Every pre-named edge case handled per its disposition. +- [ ] **SDoD3.** Reviewer verdict: accept on `projects/unify-query-operations/reviews/code-review.md` (the same review log slice 1 used, scoped to slice 2's ACs). +- [ ] **SDoD4.** Manual-QA: **N/A — no user-observable change.** The slice registers operations in the registry but neither authoring surface reads them (legacy surfaces still active). End-to-end ORM queries and sql-builder `fns` calls produce byte-identical SQL before and after this slice. Slice 3 is the first slice with user-observable change (the cipherstash trait tightening + the orderBy callback accessor split). +- [ ] **SDoD5.** Slice doesn't touch surfaces listed as out-of-scope. Specifically: no edits to `packages/3-extensions/sql-orm-client/src/model-accessor.ts`, `packages/3-extensions/sql-orm-client/src/types.ts:309+` (`COMPARISON_METHODS_META`), `packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts` (`BuiltinFunctions`), or any `asc` / `desc` ordering primitive. +- [ ] **SDoD6.** `Contract['queryOperationTypes']` includes the family's 15 op names at the type level. A snapshot or round-trip test confirms the emitted `contract.d.ts` carries the alias intersection. +- [ ] **SDoD7.** `context.queryOperations.entries()` contains the 15 family op names at runtime. A direct test on the registry, not via either consumer surface. +- [ ] **SDoD8.** Trait gating verified at registry-assembly time: trait-targeted ops index under codecs whose descriptor's `traits` set matches; `any: true` ops index under every codec; no-`self` ops do not surface on any codec's per-column index. A direct test on the registry's per-codec index, not via the ORM accessor. +- [ ] **SDoD9.** No regression in existing tests — `pnpm test:packages` workspace-wide for the 5-package targeted set (operations, sql-contract, family-sql, sql-orm-client, cipherstash, pgvector) is byte-identical to the pre-slice state, modulo the new family-sql tests added in this slice. + +## Open Questions + +1. **Should the family contribute `codecs: () => []` explicitly or should `SqlStaticContributions.codecs` become optional?** Working position: explicit empty contribution. Reasoning: keeping `codecs` required on `SqlStaticContributions` enforces the invariant that every SQL contributor declares its codec contribution (even if empty). The family's `codecs: () => []` makes the empty contribution intentional and grep-discoverable. Alternative considered: making `codecs?` optional and the family omitting it — adds an undefined-check to the `collectCodecDescriptors` loop without buying anything. Resolved at implementation; if the implementer reverses this, surface for orchestrator review. +2. **Should the slice add a `descriptor-meta.ts` file to the family package mirroring how adapters / extensions organize?** Working position: yes, for consistency. The family's `core/` currently has `runtime-descriptor.ts` and `control-descriptor.ts` separately; adding a sibling `descriptor-meta.ts` (or extending one of them) keeps the file organization aligned with `packages/3-extensions/pgvector/src/core/descriptor-meta.ts`. The implementer chooses placement; the slice plan can refine. +3. **Lowering parity with `COMPARISON_METHODS_META` and `BuiltinFunctions`.** The family's `impl`s must produce byte-identical AST nodes to today's two surfaces for the slice 3 deletion to be a no-op on emitted SQL. Working position: copy the lowering shape directly from the two surfaces (e.g. `BinaryExpr` for `eq` / `neq` / `gt` / etc., `NullCheckExpr` for `isNull` / `isNotNull`, `AndExpr` / `OrExpr` for `and` / `or`, `ExistsExpr` for `exists` / `notExists`, `BinaryExpr` with `op='in'` and a `ListExpression` for `in` / `notIn`). The implementer verifies parity by inspecting the AST output before and after for a fixed set of `where` clauses. +4. **Type-level helper naming for FR17.** Working position: `EqualityCodecId` / `OrderCodecId` / `TextualCodecId` as helper types in `types/operation-types.ts`. The implementer can rename for clarity; the constraint is they resolve to the union of CT codec ids whose trait sets include the relevant trait, matching ADR 203's "How matching works" mechanism for `fns.ilike`. + +## References + +- Parent project: [`../../spec.md`](../../spec.md) FR1-FR4, FR15-FR17, AC2/AC5/AC6/AC11/AC12. Project plan slice 2 description. +- Linear issue: TML-2354 (project-level; no per-slice sub-issue). Per the project plan's amended delivery model (single PR at project close), this slice does not open its own PR. +- ADR 203 (trait-targeted operation arguments) and ADR 206 (operations as TypeScript functions) — the patterns the family factory follows. +- Pattern references in-repo: + - `packages/3-extensions/pgvector/src/core/descriptor-meta.ts:17-58` — the model for `sqlFamilyOperations()`. + - `packages/3-extensions/pgvector/src/types/operation-types.ts` — the model for `QueryOperationTypes` type-only twin. + - `packages/3-extensions/pgvector/src/core/descriptor-meta.ts:97-103` — the model for the family's `types.queryOperationTypes` slot. + - `packages/2-sql/5-runtime/src/sql-context.ts:766-770` — the contributors array to extend. + - `packages/1-framework/1-core/framework-components/src/control/control-stack.ts:111-124,353` — the emitter alias-aggregation step (already iterates `family`). + - `packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts:137-161` — `createBuiltinFunctions()` is the source for the 15 ops' lowering shapes (slice 3 deletes it; slice 2 copies the shapes). + - `packages/3-extensions/sql-orm-client/src/types.ts:309-378` — `COMPARISON_METHODS_META` is the source for trait gates and binary-op lowering parity. diff --git a/projects/unify-query-operations/slices/self-any-arm/plan.md b/projects/unify-query-operations/slices/self-any-arm/plan.md new file mode 100644 index 0000000000..4888f1f287 --- /dev/null +++ b/projects/unify-query-operations/slices/self-any-arm/plan.md @@ -0,0 +1,110 @@ +# Slice plan: self-any-arm + +**Spec.** [`./spec.md`](./spec.md). +**Parent project.** [`projects/unify-query-operations/`](../../). +**Linear.** TML-2354 (project-level; no per-slice sub-issue; the PR title prefix is `tml-2354:`). +**Branch.** `unify-op-registries` (the project working branch). +**PR-cap.** One PR for both dispatches combined. + +## Decomposition rationale + +Two dispatches. The natural joint is the package boundary: D1 lands the **registry-primitive layer** (the type extension + the validator + its tests, across `@prisma-next/operations` and the paired `@prisma-next/sql-contract` type); D2 lands the **ORM consumer layer** (the only runtime branch and the only type-level matcher that switch on the discriminant, both in `@prisma-next/sql-orm-client`, plus an integration test that exercises end-to-end wiring of `self: { any: true }` through a synthetic operation). + +This joint produces a clean stable state at the dispatch boundary: after D1, the registry primitive accepts the new arm and the contract type carries it, but no consumer is forced to handle it yet (the existing one branch silently ignores any op registered with `any: true` — and the slice registers none, so the broken-intermediate-state risk is zero). After D2, the only consumer handles all three arms and is exercised by an integration test. + +A single combined dispatch would total ~80 LoC across 5 source files + 2 test files — pushing into M+ territory by file-count and multi-discipline (type extension + validator + runtime + type-level matcher + tests in two unrelated packages). Splitting into M + S keeps each dispatch comfortably under its size bucket and lets the orchestrator WIP-inspect both layers independently. + +## Dispatches + +### Dispatch 1: Registry-primitive layer — `any: true` arm + validator + tests + +**Intent.** Add the `{ readonly any: true }` arm to `SelfSpec` (`@prisma-next/operations`) and the parallel `QueryOperationSelfSpec` (`@prisma-next/sql-contract`). Extend `createOperationRegistry`'s registration validator at `packages/1-framework/1-core/operations/src/index.ts:42-50` so exactly one of `codecId`, `traits`, `any` must be set when `self` is present. Update the existing two validator-test expected error messages to match the reworded messages, and add three new test cases: positive `{ any: true }` accepted, ambiguous `{ any: true, codecId: ... }` rejected, ambiguous `{ any: true, traits: [...] }` rejected. **What stays the same.** No new operation registers with the new arm; no `OpMatchesField` change; no `model-accessor.ts` change; no runtime behaviour change for any existing operation (this slice's existing-operations regression bar is "`pnpm test:packages` for sql-orm-client and operations is byte-identical except for the validator error-message strings"). + +**Files in play.** + +- `packages/1-framework/1-core/operations/src/index.ts` — extend `SelfSpec` (lines 12-14) and the validator (lines 42-50). +- `packages/1-framework/1-core/operations/test/operations-registry.test.ts` — update existing tests' expected messages (lines 51-84) and add three new validator tests; mirror the existing `// @ts-expect-error` pattern for negative cases. +- `packages/2-sql/1-core/contract/src/types.ts` — extend `QueryOperationSelfSpec` (lines 99-101) with the matching arm. Add a one-line comment cross-referencing `SelfSpec` so a future maintainer doesn't drift them. + +**"Done when" gates.** + +- [ ] `pnpm --filter @prisma-next/operations build` clean. +- [ ] `pnpm --filter @prisma-next/sql-contract build` clean (re-emits `dist/*.d.mts` carrying the new arm). +- [ ] `pnpm typecheck` clean workspace-wide. Required because `@prisma-next/sql-orm-client`'s `OpMatchesField` consumes `QueryOperationSelfSpec` structurally; the new arm widens the type and TypeScript must still accept the existing pattern-match (the matcher continues to reach `false` for any `Self` it doesn't recognise — D2 fixes that). +- [ ] `pnpm --filter @prisma-next/operations test` green. Three new validator tests cover the positive + two ambiguous-combination negatives. Existing tests at lines 51-84 still pass with updated expected-message strings. +- [ ] `pnpm lint:deps` clean (no new package imports introduced). +- [ ] Grep gate: `rg 'any\??:\s*boolean' packages/1-framework/1-core/operations/src/` returns zero hits — confirms F2 avoidance (the new field is `any: true`, not `any?: boolean`). +- [ ] Intent-validation: diff is confined to the three named files. No edit to `model-accessor.ts`, no edit to `sql-orm-client/src/types.ts`, no edit to any operation that registers with `self`. If the implementer drifts toward D2's surface, the WIP inspection catches it. +- [ ] Edge cases from slice spec covered by this dispatch: "Registered with only `any: true`" (positive validator test), "any + codecId combination" (negative test), "any + traits combination" (negative test), "`self: {}` empty" (existing test updated), "`self: { traits: [] }` empty array" (existing test unchanged), "naming choice" (the arm is named `any`), "validator error message wording" (three distinct messages, all matching the existing tone). +- [ ] Destructive git operations forbidden without orchestrator approval (per F5 standard list: `git clean -f*`, `git reset --hard`, `git stash drop`, `git stash clear`, `git checkout -- .`, `git rm -r --force`, `rm -rf` against the worktree). + +**Size.** M. Three files; ~50 LoC; one design judgment (validator error message structure); blast radius confined to two framework-core packages whose downstream typecheck D1 itself validates. + +**Model tier.** Opus (orchestrator tier). The dispatch carries design judgment (error-message wording, decision to keep the two type defs in lock-step with a cross-reference comment) and touches framework-core surfaces; per [`model-tier.md`](../../../../drive/calibration/model-tier.md), substrate-change / design-judgment work routes to Opus. + +**DoR confirmed:** ✓ Spec exists; intent stated; files-in-play named; "done when" binary; size M; failure modes F2 (avoid optional `any?:`) and F5 (destructive git) named; edge cases mapped; affected packages identified; downstream `@prisma-next/sql-orm-client` typecheck named because D1 modifies `packages/1-framework-core` / `packages/2-sql/1-core` surfaces; no fixture regen (no IR/emitter/serialiser change). + +### Dispatch 2: ORM consumer layer — model-accessor branch + `OpMatchesField` clause + integration test + +**Intent.** Extend the ORM model accessor's `self` resolution loop at `packages/3-extensions/sql-orm-client/src/model-accessor.ts:71-85` with a third branch: when `self.any === true`, index the op under every codec known to `context.codecDescriptors`. Extend the type-level matcher `OpMatchesField` at `packages/3-extensions/sql-orm-client/src/types.ts:234-248` with an `any`-first clause that returns `true` for any field codec when `Self extends { readonly any: true }`. Add an integration test in `packages/3-extensions/sql-orm-client/test/model-accessor.test.ts` that synthesizes a `self: { any: true }` operation via a test-local registry and asserts the op appears as a method on every column of the test fixture, irrespective of the column's codec traits. **What stays the same.** No new operation in production code uses the new arm; the `COMPARISON_METHODS_META` loop, the extension-method factory, the relation accessor, and every other call site in `model-accessor.ts` are untouched. No change to `@prisma-next/operations`, `@prisma-next/sql-contract`, or the validator. + +**Files in play.** + +- `packages/3-extensions/sql-orm-client/src/model-accessor.ts` — add the third branch in the loop at lines 71-85; ~5 lines. +- `packages/3-extensions/sql-orm-client/src/types.ts` — add the `any`-first clause to `OpMatchesField` at lines 234-248; ~4 lines. +- `packages/3-extensions/sql-orm-client/test/model-accessor.test.ts` — add one `describe`/`it` block (or extend an existing one) registering a synthetic op with `self: { any: true }` and asserting it appears on every column accessor in the existing fixture; use the file's existing helpers (`getTestContext`, `createModelAccessor`, the `makeDescriptors` helper at line ~45 if mixed codec descriptors are needed). + +**"Done when" gates.** + +- [ ] `pnpm --filter @prisma-next/sql-orm-client build` clean. +- [ ] `pnpm typecheck` clean workspace-wide. +- [ ] `pnpm --filter @prisma-next/sql-orm-client test` green. Includes the new integration test exercising `self: { any: true }` end-to-end (registration → runtime indexing → column-method surface). +- [ ] `pnpm lint:deps` clean. +- [ ] Intent-validation: diff is confined to the three named files. No edit to `@prisma-next/operations`, `@prisma-next/sql-contract`, or any other consumer. If the implementer drifts (e.g. starts registering `isNull` / `isNotNull` with the new arm — that is slice 2's work), the WIP inspection catches it. +- [ ] Edge cases from slice spec covered by this dispatch: "Runtime branch indexes under every codec" (covered by the new integration test asserting the synthetic op appears on every column), "type-level matcher returns `true` for any field codec" (the integration test's column-method surface is type-asserted by the existing `OpMatchesField`-driven `FieldOperations` type — TypeScript surfaces a regression as a typecheck failure on the assertion), "`OpMatchesField` ordering: `any` first" (a one-line comment in the new clause documents the intent so a later maintainer doesn't reorder it). +- [ ] Discovery via grep, not test suite (F3 avoidance): `rg 'self\.codecId\|self\.traits' packages/` returned exactly one consumer site (`model-accessor.ts:71-85`) at slice-spec time. Re-run before merge; if a new consumer site has appeared, halt and route to `drive-discussion`. +- [ ] Destructive git operations forbidden without orchestrator approval (F5 standard list). + +**Size.** S. Two production files (~10 LoC total) + one integration test (~20 LoC). Confined to one package. Mechanical given the spec's pre-research; D1's typecheck cascade has already verified the structural extension. + +**Model tier.** Sonnet (mid tier). Mechanical extension of a pre-researched consumer site; no new design decisions; the structural shape was settled in D1 and the spec. + +**DoR confirmed:** ✓ Spec exists; intent stated; files-in-play named; "done when" binary; size S; failure modes F3 (consumer discovery already done — re-grep before merge) and F5 (destructive git) named; edge cases mapped; affected package identified; downstream `pnpm typecheck` is workspace-wide which catches any consumer of `@prisma-next/sql-orm-client`'s exported types; no fixture regen. + +## Dependencies between dispatches + +D2 depends on D1. The dependency is structural: D2's `OpMatchesField` clause matches on `Self extends { readonly any: true }`, which requires `QueryOperationSelfSpec` to carry the `{ any: true }` arm (otherwise the conditional clause is dead code). Sequential delivery, no parallelisation. + +## Cross-references + +### Failure modes threaded + +- [F2 — Constructor magic for optional fields](../../../../drive/calibration/failure-modes.md#f2-constructor-magic-for-optional-fields). The new field is **required** `any: true`, not optional `any?: boolean`. Threaded into D1's grep gate (`rg 'any\??:\s*boolean'`). +- [F3 — Discovery via test suite instead of grep](../../../../drive/calibration/failure-modes.md#f3-discovery-via-test-suite-instead-of-grep). Consumer discovery already complete at slice-spec time (one runtime consumer, one type-level consumer). Threaded into D2's "done when" as a re-grep before merge. +- [F5 — Destructive git operations](../../../../drive/calibration/failure-modes.md#f5-destructive-git-operations-executed-by-subagents-without-orchestrator-approval). Standard non-negotiable disposition in both dispatches. + +### Grep library entries + +- `rg 'any\??:\s*boolean'` — D1 gate to confirm F2 avoidance (the field is property-literal-typed, not boolean). +- `rg 'self\.codecId\|self\.traits' packages/` — D2 gate to confirm the consumer count is unchanged from the slice-spec snapshot (one runtime site, in `model-accessor.ts`). +- `rg 'QueryOperationSelfSpec' packages/` — sanity grep both dispatches can run; the slice-spec snapshot was 4 hits (type def, re-export, type-level consumer in `OpMatchesField`'s neighbourhood via the import chain, and a cipherstash doc-comment). + +## Slice-DoD reachability + +Every condition in the slice-DoD is covered by one or both dispatches: + +| Slice-DoD condition | Covered by | +|---|---| +| **SDoD1.** `pnpm typecheck` + `pnpm test:packages` + `pnpm lint:deps` + intent-validation. | D1 "done when" + D2 "done when" (typecheck workspace-wide is in both). | +| **SDoD2.** Every pre-named edge case handled per its disposition. | Slice-spec edge cases mapped per-dispatch in each "Edge cases covered" sub-list. | +| **SDoD3.** Reviewer verdict accept on `projects/unify-query-operations/reviews/code-review.md`. | End-of-slice; the dispatch loop terminates when the reviewer reports SATISFIED. | +| **SDoD4.** Manual-QA N/A (no user-observable change). | Already declared in the spec; neither dispatch introduces user-observable surface. | +| **SDoD5.** Slice doesn't touch out-of-scope surfaces. | Intent-validation gate in both dispatches; D1 explicitly forbids touching `model-accessor.ts` or `sql-orm-client/src/types.ts`; D2 explicitly forbids touching `@prisma-next/operations` or `@prisma-next/sql-contract`. | +| **SDoD6.** `QueryOperationSelfSpec` and `SelfSpec` carry semantically identical arms in the same order; cross-reference comment present. | D1 (both type defs modified together; cross-reference comment added). | +| **SDoD7.** `OpMatchesField` returns `true` for the `any: true` arm against any field codec; no regression for existing arms. | D2's integration test + workspace typecheck. | + +## Risks + +1. **F2 backslide via `any: boolean`.** If the implementer drafts `any?: boolean` instead of `any: true`, the validator's "exactly one set" check becomes ambiguous (is `any: false` "unset"?) and TypeScript can no longer distinguish a deliberately-set `false` from an omitted field. Mitigated by D1's grep gate and the explicit spec language ("required `any: true`, not optional"). If the gate hits, halt D1 and re-implement. +2. **Lock-step drift between `SelfSpec` and `QueryOperationSelfSpec`.** The two type defs live in different packages and one is publicly exported. If only one is extended, downstream consumers (or future contributors authoring contract-level operations) can ship a runtime registration that the contract type can't represent. Mitigated by D1 modifying both together and adding a cross-reference comment (SDoD6). +3. **`OpMatchesField` ordering reorder by a later refactor.** The `any`-first clause is correct in any order (the discriminated union guarantees mutual exclusion), but reading it first matches documentation intent. A later maintainer who reorders for "consistency with the runtime branch" risks confusing the next reader. Mitigated by a one-line comment in D2's clause. diff --git a/projects/unify-query-operations/slices/self-any-arm/spec.md b/projects/unify-query-operations/slices/self-any-arm/spec.md new file mode 100644 index 0000000000..db3dd51448 --- /dev/null +++ b/projects/unify-query-operations/slices/self-any-arm/spec.md @@ -0,0 +1,126 @@ +# Slice: self-any-arm + +_Parent project: [`projects/unify-query-operations/`](../../). This slice is foundation work for the project's FR5 and AC6 — adding the third `{ any: true }` arm to `SelfSpec` so a later slice can register `isNull` / `isNotNull` as operations reachable on every codec regardless of its declared traits._ + +## At a glance + +Extend the operation registry's `SelfSpec` discriminated union with a third arm — `{ readonly any: true }` — meaning *"this operation applies to every codec, regardless of trait set."* This is purely structural: no operation registers with the new arm yet, no user-visible authoring surface changes, the SQL family registry doesn't exist yet. The slice is done when (a) every consumer that switches on the existing `codecId | traits` discrimination handles the third arm, (b) the registration validator accepts `{ any: true }` and rejects ambiguous combinations, and (c) the type-level dispatch matcher `OpMatchesField` returns `true` for any field codec when `self.any === true`. + +The motivation is that the project's later slices need to express *"this operation reaches every codec column"* for `isNull` / `isNotNull` — operations that today live in `COMPARISON_METHODS_META` with `traits: []` (meaning "no trait required"). The current `SelfSpec` cannot express that intent — a registered `traits: []` is rejected by the validator at `index.ts:44-46` precisely because an empty traits array is indistinguishable from a missing one. Rather than overloading `traits: []`, we add an explicit arm that says what we mean. + +## Scope + +### In scope + +- `packages/1-framework/1-core/operations/src/index.ts` — add the `{ readonly any: true }` arm to `SelfSpec`; extend the registration validator at `createOperationRegistry` (lines 42-50) so exactly one of `codecId`, `traits`, `any` must be set when `self` is present. +- `packages/2-sql/1-core/contract/src/types.ts` — add the matching `{ readonly any: true }` arm to the public-export `QueryOperationSelfSpec` (lines 99-101). This keeps the contract-emitted type in sync with the runtime type. (See § Contract impact.) +- `packages/3-extensions/sql-orm-client/src/model-accessor.ts` — extend the `self` resolution loop (lines 71-85) with a branch that, when `self.any === true`, indexes the op under every codec known to `context.codecDescriptors`. +- `packages/3-extensions/sql-orm-client/src/types.ts` — extend the type-level `OpMatchesField` matcher (lines 234-248) with a clause that returns `true` when `Self extends { readonly any: true }`, regardless of the field's codec id. +- `packages/1-framework/1-core/operations/test/operations-registry.test.ts` — add registration-validator tests covering: (a) `self: { any: true }` accepted, (b) `self: { any: true, codecId: '...' }` rejected, (c) `self: { any: true, traits: [...] }` rejected. The existing tests for the two prior arms must still pass unchanged. + +### Out of scope (this slice) + +- Registering any built-in operation (`isNull`, `isNotNull`, or otherwise) with the new arm. That is slice 2's work (`family-ops-factory`). +- Removing `COMPARISON_METHODS_META`. That is slice 3's work (`collapse-consumers`). +- The SQL family `queryOperations()` factory itself. Slice 2. +- ORM ordering registry. Slice 3. +- Any change to the contract emitter, the sql-builder's `Functions` derivation, or the `fns` Proxy. Later slices. +- Any change to existing operations' `self` declarations (cipherstash, pgvector, etc.). They remain `{ codecId }` / `{ traits }` as authored — purely additive. + +## Approach + +The change is a discriminated-union extension in two parallel type definitions plus three consumer-site updates. Both type definitions live in different packages by layering convention but represent the same concept — runtime registration vs contract emission — and must stay in lock-step. + +**1. Type extension.** `SelfSpec` in `packages/1-framework/1-core/operations/src/index.ts:12-14` and `QueryOperationSelfSpec` in `packages/2-sql/1-core/contract/src/types.ts:99-101` each gain a third arm using the same mutual-exclusion pattern the existing two arms use (the `?: never` cross-clauses): + +```ts +// Illustrative — final placement / formatting is the implementer's call. +export type SelfSpec = + | { readonly codecId: string; readonly traits?: never; readonly any?: never } + | { readonly traits: readonly string[]; readonly codecId?: never; readonly any?: never } + | { readonly any: true; readonly codecId?: never; readonly traits?: never }; +``` + +Using `any: true` (rather than `any: boolean`) keeps "the field is set" and "the field is `true`" identical at the type level — there is no meaningful `any: false`. + +**2. Runtime validator.** `createOperationRegistry` at `packages/1-framework/1-core/operations/src/index.ts:42-50` today computes `hasCodecId` + `hasTraits` and rejects neither/both. Extend with `hasAny`, then enforce "exactly one of the three is set." The error messages should be precise — at least three messages: "self has none of codecId/traits/any," "self combines codecId and traits," "self combines any with codecId or traits." + +**3. ORM model accessor resolution loop.** `packages/3-extensions/sql-orm-client/src/model-accessor.ts:71-85` walks every registered operation and indexes it under the codecs it applies to. Today: `self.codecId` indexes under one codec; `self.traits` indexes under every codec whose descriptor's `traits` set contains every required trait. Extend with a third branch: `self.any === true` indexes the op under every codec known to `context.codecDescriptors` (the same iteration the trait branch already uses, but without the trait filter): + +```ts +// Illustrative — at the same indent as the existing two branches. +} else if (self.any === true) { + for (const descriptor of context.codecDescriptors.values()) { + registerOp(descriptor.codecId, op); + } +} +``` + +**4. Type-level matcher `OpMatchesField`.** `packages/3-extensions/sql-orm-client/src/types.ts:234-248` is a conditional-type chain that returns `true` when the field's codec matches the operation's `self`. Extend the chain with an `any`-first clause: if `Self extends { readonly any: true }` return `true` immediately (any field codec matches). Place the `any` clause first because it is the most permissive — the order doesn't affect correctness for well-formed `SelfSpec` (the discriminated union ensures exactly one arm matches), but reading it first matches the documentation intent. + +**5. Tests.** Mirror the existing three validator tests in `operations-registry.test.ts:51-84` for the new arm: one positive case (`{ any: true }` accepted), two negative cases (`{ any: true, codecId: ... }` rejected; `{ any: true, traits: [...] }` rejected). The existing `// @ts-expect-error` pattern carries over for the negative cases, since the discriminated-union constraint makes them ill-formed at compile time as well as at runtime. No new type-level test files are added in this slice — `pnpm typecheck` of the existing consumers covers the structural extension. + +The slice is structurally additive: existing call sites that pass `{ codecId: ... }` or `{ traits: [...] }` keep working unchanged. The only call sites that need new code are the four sites named above, all of which switch on the discriminant and need a third branch. + +## Edge cases (Example-Mapping) + +| Edge case | Disposition | Notes | +|---|---|---| +| `self: { any: true }` registered, no `codecId` / `traits` | Handle | Validator accepts; resolution loop indexes the op under every codec in `context.codecDescriptors`; `OpMatchesField` returns `true` for any field. Positive registration test + a runtime test that the op appears on every column. | +| `self: { any: true, codecId: 'pg/text@1' }` | Handle | Validator rejects with "self combines any with codecId or traits." TypeScript should also reject at compile time via the `?: never` cross-clauses; `@ts-expect-error` in the negative test. | +| `self: { any: true, traits: ['equality'] }` | Handle | Same as above with `traits` instead of `codecId`. | +| `self: { any: false }` | Explicitly out | `any` is typed `true`, not `boolean`. The validator never sees `any: false` because TypeScript rejects it. If runtime JSON hydration ever produced `{ any: false }`, the validator's "exactly one of three must be set" check would reject it as "none of codecId/traits/any set" — but no JSON hydration path currently produces `SelfSpec`, so this is theoretical. No runtime branch added for it. | +| `self: {}` (no codecId, no traits, no any) | Handle | Validator continues to throw "self has none of codecId/traits/any." Existing test at `operations-registry.test.ts:51-61` covers the prior shape; updated error message; the test message string updates accordingly. | +| `self: { traits: [] }` (empty traits array) | Handle | Existing rejection at `index.ts:44` (`hasTraits = ... && traits.length > 0`) stays in place — the current test at lines 63-72 already covers this. Empty `traits` is not promoted to `any: true`; that would be a silent semantic change. The caller must explicitly choose. | +| `self` omitted entirely (operation has no `self`) | Explicitly out | Existing behaviour: `self?` is optional; operations without `self` are sql-builder-only and not surfaced as column methods. Unchanged by this slice. Existing test at `operations-registry.test.ts:99-107` covers this. | +| Type-level consumer that switches on `codecId | traits` today | Handle | The only one inside this slice's blast radius is `OpMatchesField` itself (extended in scope). External extension type definitions (cipherstash's `QueryOperationTypes`, pgvector's equivalent) only **author** `SelfSpec` shapes — they don't pattern-match on the discriminant. The grep at `packages/3-extensions/cipherstash/src/types/operation-types.ts:62` is a documentation comment that references `QueryOperationSelfSpec` but doesn't switch on it — no edit needed. | +| Runtime consumer that switches on `self.codecId` / `self.traits` today | Handle | The only one is the resolution loop at `model-accessor.ts:71-85` (extended in scope). Confirmed via `rg 'self\.codecId\|self\.traits' packages/`. | +| `OpMatchesField` ordering: `any` first vs last in the conditional chain | Handle | Place `any: true` first because it is the most permissive and documents intent. Functional correctness does not depend on ordering — the discriminated union guarantees at most one arm matches well-formed input. A test or comment should record the intent so a later maintainer doesn't reorder it for "consistency" with the runtime branch order. | +| Reflective consumer (debug printer / serializer) that doesn't know about `any` | Defer | No such consumer found in the codebase (`rg` confirms). If one surfaces during slice 2 / 3, treat as a discovered-edge-case stop-condition per project I12, route to `drive-discussion`. | +| Naming: `any: true` vs `applyToAll: true` vs `universal: true` | Handle | `any` chosen to match the cardinality vocabulary already used elsewhere in the project (`any: true` reads as "any codec"). The field is on a type named `SelfSpec`, so the noun is clear. If review pushes back, naming can change without affecting the structural plan. | +| Validator error message wording | Handle | Three distinct messages, all matching the existing tone ("Operation \"\" self ..."): (a) `"self has none of codecId, traits, or any"` for the empty case (replaces the existing `"self has neither codecId nor traits"`), (b) `"self combines codecId and traits"` for the existing both-set case (rewording the existing message), (c) `"self combines any with codecId or traits"` for the new ambiguous case. Existing tests' expected-message strings update in lock-step. | + +## Contract impact + +**Affected contract-surface types.** `QueryOperationSelfSpec` at `packages/2-sql/1-core/contract/src/types.ts:99-101` is publicly re-exported from `@prisma-next/sql-contract/types` (`packages/2-sql/1-core/contract/src/exports/types.ts:18`). The contract emitter (not modified in this slice) already lifts `QueryOperationTypeEntry` into the generated `contract.d.ts` via the `types.queryOperationTypes` slot, so the new arm flows through to every downstream consumer at the type level. + +**Migration plan for downstream consumers.** Purely additive. No downstream consumer pattern-matches on the discriminant; the new arm widens the type without invalidating existing entries. Verified via `rg 'QueryOperationSelfSpec' packages/` — three hits total: the type definition, the re-export, and a single documentation comment in cipherstash (`packages/3-extensions/cipherstash/src/types/operation-types.ts:62`) that references the type's name but does not switch on it. No `@prisma-next/*` extension authors `self: { any: true }` after this slice; the new arm becomes consumable when slice 2 registers `isNull` / `isNotNull` with it. + +## Adapter impact + +N/A. No adapter (`packages/3-targets/**`) code is touched. The slice's runtime changes are confined to `@prisma-next/operations` (the registry primitive) and `@prisma-next/sql-orm-client` (the one consumer that switches on the discriminant). Verified via `rg 'self\.codecId\|self\.traits' packages/3-targets/` — zero hits. + +## ADR pointer + +The project's slice 5 (`adr-close-out`) commits to drafting a new ADR ("ADR NNN — Unified SQL-family operation registry") that records the unified-registry decision and explicitly supersedes the carve-outs in ADR 203 and ADR 206. The new `{ any: true }` arm is part of the decision the close-out ADR documents; this slice does not draft a separate ADR. If review surfaces an architectural question this slice should answer in its own ADR rather than defer to the close-out ADR, the spec amends via `drive-discussion`. + +## Slice Definition of Done + +- [ ] **SDoD1.** All "Done when" gates from the slice plan pass: `pnpm typecheck` green workspace-wide; `pnpm test:packages` green for `@prisma-next/operations` and `@prisma-next/sql-orm-client`; `pnpm lint:deps` green; intent-validation confirms the diff matches the brief (no scope creep into slice-2 / slice-3 territory). +- [ ] **SDoD2.** Every pre-named edge case handled per its disposition. +- [ ] **SDoD3.** Reviewer verdict: accept on `projects/unify-query-operations/reviews/code-review.md`. +- [ ] **SDoD4.** Manual-QA script: **N/A — no user-observable change.** The slice extends an internal type and validator; no `model.field.xxx()` surface appears or disappears; no error message a user could see changes (the validator's new error messages are framework-author-facing, not end-user-facing). Slice 2 is the first slice in this project that surfaces user-observable change; manual-QA discipline picks up there. +- [ ] **SDoD5.** Slice doesn't touch surfaces listed as out-of-scope. Specifically: no operation registers with `self: { any: true }` in this slice (slice 2's job); `COMPARISON_METHODS_META` and `BuiltinFunctions` remain in place; no SQL family `queryOperations()` factory shipped. +- [ ] **SDoD6.** `QueryOperationSelfSpec` (`packages/2-sql/1-core/contract/src/types.ts`) and `SelfSpec` (`packages/1-framework/1-core/operations/src/index.ts`) carry semantically identical arms in the same order; a deliberate convention comment on at least one of them references the other so a future maintainer doesn't drift them. +- [ ] **SDoD7.** `OpMatchesField` (`packages/3-extensions/sql-orm-client/src/types.ts`) returns `true` for the `any: true` arm against any field codec. A `pnpm typecheck` test on the existing model-accessor surface confirms no regression for existing trait-targeted / codec-id-targeted entries. + +## Open Questions + +1. **Naming: `any: true` vs alternative wording.** Working position: `any: true`, as drafted in the approach. The name is short, reads as "applies to any codec," and matches the field's intent. Alternatives considered: `applyToAll`, `universal`, `everyCodec`. None reads better; if review pushes back, naming can change without affecting structural scope. Resolved at slice-plan time or earlier. +2. **Should the validator's error message for the empty-`self` case mention the new arm?** Working position: yes — the message becomes `"self has none of codecId, traits, or any"`. The trade-off: a longer message vs. an accurate one. Accuracy wins; the existing test's expected-message string updates in lock-step. +3. **Should the type-level `OpMatchesField` clause for `any: true` go first or last in the conditional chain?** Working position: first, as drafted. Reasoning is documentation-of-intent — `any: true` is "the most permissive case, handled before the codec/trait narrowing." Functional behaviour identical either way given the discriminated union's mutual exclusion. Resolved during implementation if a clearer ordering surfaces. +4. **Does extending `QueryOperationSelfSpec` (public contract surface) belong in this slice or slice 2?** Working position: this slice. Reasoning: keeping the runtime type (`SelfSpec`) and the contract-emitted type (`QueryOperationSelfSpec`) in lock-step is structural — splitting the extension across slices means slice 1 ships a runtime type that the contract type can't represent, which is a half-finished surface (forbidden by project conventions). The change to `QueryOperationSelfSpec` is purely additive and risk-free (no downstream consumer switches on the discriminant), so the lock-step extension is cheap. Resolved here unless slice plan surfaces a reason to split. + +## References + +- Parent project: [`projects/unify-query-operations/spec.md`](../../spec.md) — FR5 (the new arm definition), AC6 (the project-level acceptance criterion this slice partially satisfies). +- Parent project plan: [`projects/unify-query-operations/plan.md`](../../plan.md) § Slice `self-any-arm`. +- Linear issue: TML-2354 (project-level tracking issue; no per-slice sub-issue per project plan). Slice PR title carries `tml-2354:` prefix. +- Calibration: failure modes [F2](../../../../drive/calibration/failure-modes.md#f2-constructor-magic-for-optional-fields) (avoid optional `any?: boolean` — use required `any: true`), [F3](../../../../drive/calibration/failure-modes.md#f3-discovery-via-test-suite-instead-of-grep) (consumer discovery already done via `rg`; no test-suite-as-discovery loop expected). Grep library: `: any\b|\bany\[\]` is a forbidden-`any` check unrelated to this slice's `any: true` field name; `pnpm lint:deps` separately confirms. +- Codebase touchpoints (anchors for the slice plan): + - `packages/1-framework/1-core/operations/src/index.ts:12-14` (`SelfSpec`) + - `packages/1-framework/1-core/operations/src/index.ts:42-50` (validator) + - `packages/1-framework/1-core/operations/test/operations-registry.test.ts:51-107` (existing validator tests) + - `packages/2-sql/1-core/contract/src/types.ts:99-101` (`QueryOperationSelfSpec`) + - `packages/3-extensions/sql-orm-client/src/model-accessor.ts:71-85` (resolution loop) + - `packages/3-extensions/sql-orm-client/src/types.ts:234-248` (`OpMatchesField`) diff --git a/projects/unify-query-operations/spec.md b/projects/unify-query-operations/spec.md new file mode 100644 index 0000000000..8a4e880c33 --- /dev/null +++ b/projects/unify-query-operations/spec.md @@ -0,0 +1,239 @@ +# Summary + +Unify built-in and extension query operations behind a single SQL-family operation registry. Delete `COMPARISON_METHODS_META` (ORM) and `BuiltinFunctions` (sql-builder); both authoring surfaces source every operation — common or not — from the same registry. Prefer trait-targeted `self` over codec-id `self` for common operations so they apply to any codec that opts in. + +# Context + +## At a glance + +Today, a SQL field's comparison surface is assembled from three sources that don't know about each other: + +- **ORM**: `COMPARISON_METHODS_META` — a hardcoded record in `sql-orm-client` enumerating `eq`, `gt`, `like`, `asc`, `isNull`, … with trait gates. +- **sql-builder `fns`**: `BuiltinFunctions` — a hardcoded type and matching `createBuiltinFunctions()` factory enumerating `eq`, `ne`, `and`, `or`, `exists`, `in`, … with **no trait gating** — `fns.eq(cipherstashCol, cipherstashCol)` typechecks today even though the cipherstash codec opts out of `equality`. +- **Operation registry**: the extension-facing pipeline (`SqlOperationRegistry`, the `queryOperations()` factories from contributors) — already trait-aware, already wired into both surfaces, used by `ilike`, `cosineDistance`, `cipherstashEq`, etc. + +The two authoring surfaces will source every operation from a single registry — call it the **SQL family operations registry** — shipped by the SQL family (`@prisma-next/family-sql`). Built-ins (`eq`, `ne`, `gt`, `gte`, `lt`, `lte`, `like`, `in`, `notIn`, `isNull`, `isNotNull`, `asc`, `desc`, `and`, `or`, `exists`, `notExists`) are registered there as `SqlOperationDescriptor` entries authored as TypeScript functions per [ADR 206](../../docs/architecture%20docs/adrs/ADR%20206%20-%20Operations%20as%20TypeScript%20functions.md). Where applicable, their `self` is trait-targeted (`eq` → `{ traits: ['equality'] }`, `gt` → `{ traits: ['order'] }`, `like` → `{ traits: ['textual'] }`), so a codec that does not declare the trait does not see the operation on either surface. `COMPARISON_METHODS_META`, `BuiltinFunctions`, and `createBuiltinFunctions()` are deleted. + +_Illustrative — exact shape of one built-in once migrated. Final names and helpers are the implementer's choice; the binding constraint is that the entry sits in the SQL family registry (inside `@prisma-next/family-sql`) and the surfaces source it from there._ + +```ts +// In @prisma-next/family-sql +export function sqlFamilyOperations(): SqlOperationDescriptors { + return { + eq: { + self: { traits: ['equality'] }, + impl: ( + a: TraitExpression<'equality', boolean, CT> | null, + b: CodecExpression | null, + ): Expression => { /* ...buildOperation(...) */ }, + }, + gt: { + self: { traits: ['order'] }, + impl: /* same shape, traits: ['order'] */, + }, + and: { + // No self — applies regardless of codec; not reachable as a column method. + impl: (...exprs: Expression[]): Expression => { /* ... */ }, + }, + // … like, in, notIn, isNull, isNotNull, asc, desc, or, exists, notExists, ne, lt, lte, gte + }; +} +``` + +## Problem + +Two parallel hardcoded definitions exist for what is conceptually one set of operations. Adding a new common predicate (say, `between`) means editing `COMPARISON_METHODS_META`, editing `BuiltinFunctions` and `createBuiltinFunctions()`, and keeping the two definitions semantically aligned. Trait gating is asymmetric across them: the ORM honours codec traits (so cipherstash columns lose `.eq` because their codec declares `traits: []`), while the sql-builder `fns.eq` is generic over codec id and applies to any expression, so `fns.eq(cipherstashCol, cipherstashCol)` typechecks. The same operation has two different "is this callable here?" answers depending on which authoring surface you reach for. + +Extensions already have a clean story — they ship `queryOperations()` contributors that produce `SqlOperationDescriptor` entries with `self: { codecId }` or `self: { traits }` ([ADR 203](../../docs/architecture%20docs/adrs/ADR%20203%20-%20Trait-targeted%20operation%20arguments.md)), authored as TypeScript functions ([ADR 206](../../docs/architecture%20docs/adrs/ADR%20206%20-%20Operations%20as%20TypeScript%20functions.md)). Those entries flow into both the ORM model accessor and the sql-builder `fns` surface through the same registry. Built-ins are the exception: ADR 203 explicitly carved them out (_"Migration of built-in comparisons to trait-targeted operations"_ is listed as a non-goal) and ADR 206 echoed it (_"Changing the built-in comparison methods"_ likewise listed as a non-goal). Both ADRs were correct for what they shipped, but the carve-out has now become friction: it forces every new SQL family target to re-implement the same operator set, prevents new common operations from being added in a single place, and is the root cause of the asymmetric trait gating between ORM and sql-builder. + +Internal changes that touch the comparison surface (e.g., changing what `like` returns, or how `in` resolves codecs for its list operand) hit both hardcoded sites plus the registry-based extension dispatch — three code paths that must agree by hand. The motivating goal of this project is to make the comparison surface mechanically uniform with the rest of the registry. + +## Approach + +**One registry, two consumers.** The SQL family ships a `queryOperations()` factory ([ADR 206](../../docs/architecture%20docs/adrs/ADR%20206%20-%20Operations%20as%20TypeScript%20functions.md) style — generic over the contract's `CT` codec-types map) that registers every operation today defined in `COMPARISON_METHODS_META` and `BuiltinFunctions`. The contract-assembly layer instantiates this factory the same way it instantiates extension and adapter factories; the resulting entries land in the `SqlOperationRegistry` carried by `ExecutionContext.queryOperations`. The registry is the single source of truth for both authoring surfaces: + +- **ORM model accessor.** Today, `createScalarFieldAccessor` synthesizes two sets of methods on a column accessor: built-ins via `COMPARISON_METHODS_META` filtered by codec traits, and extension ops via the registry's per-codec index. After this project, it synthesizes one set, from the registry alone. The trait-filtering logic in the model accessor stays — it already correctly handles trait-targeted `self` — it just no longer has a second `COMPARISON_METHODS_META` loop. +- **sql-builder `fns` surface.** Today, `createFunctions` returns a Proxy that checks `createBuiltinFunctions()` first, then falls back to `operations[prop].impl`. After this project, the Proxy only checks `operations[prop].impl` — `createBuiltinFunctions` is gone. The `Functions` type drops the `BuiltinFunctions &` intersection and becomes purely `DeriveExtFunctions` (which, since the registry now also carries the built-ins, includes everything that used to live in `BuiltinFunctions`). + +**Two registries, owned by their consumers.** + +- The **SQL family operations registry** (in `@prisma-next/family-sql`) holds operations consumed by both authoring surfaces — predicates and value-returning operations. Both the ORM column accessor (for WHERE/HAVING) and the sql-builder `fns` proxy source from it. +- The **ORM ordering-operations registry** (private to `@prisma-next/sql-orm-client`) holds `asc`/`desc` and any future orderBy primitives. It is consulted only by the ORM's orderBy callback accessor. The sql-builder does not see it; it is not part of the contract's emitted `queryOperationTypes` map. + +This split reflects ownership: the contract describes operations that are interoperable across authoring surfaces; orderBy primitives are an authoring concern of the lane that uses them and don't need to be in the contract. + +**Trait-first authoring for common operations.** Every operation today in `COMPARISON_METHODS_META` already declares its trait dependency in its meta entry. Migrating them preserves that gating verbatim: + +| Operation | Registry | `self` | +|------------------------------------------|------------------|-------------------------------------| +| `eq`, `neq`, `in`, `notIn` | SQL family | `{ traits: ['equality'] }` | +| `gt`, `gte`, `lt`, `lte` | SQL family | `{ traits: ['order'] }` | +| `like` | SQL family | `{ traits: ['textual'] }` | +| `isNull`, `isNotNull` | SQL family | `{ any: true }` — every codec | +| `and`, `or`, `exists`, `notExists` | SQL family | _no `self`_ — sql-builder only | +| `asc`, `desc` | ORM ordering | `{ traits: ['order'] }` | + +**Accessors filter the SQL family registry by return shape.** Each authoring context surfaces only operations whose return shape is meaningful for that context. The descriptors themselves carry no `context` marker — the filter rule is per-accessor: + +| Accessor / surface | Filter over the SQL family registry | +|---------------------------------|----------------------------------------------------------------------------| +| WHERE-style column accessor | ops whose `self` matches the column's codec **and** whose return codec is `boolean`-traited (predicates) | +| HAVING aggregate selector | ops whose `self` matches the aggregate's return codec **and** whose return codec is `boolean`-traited | +| sql-builder `fns` proxy | every op in the family registry (`self`-matching is per call, by argument type) | +| orderBy accessor | ops from the **ORM ordering registry** whose `self` matches the column's codec | + +This kills two pieces of today's hand-curation: the `Pick<…>` in `HavingComparisonMethods` (HAVING now derives from the predicate-return filter, same as WHERE), and the cosmetic leak of `.asc()`/`.desc()` onto the WHERE-style column accessor (since `asc`/`desc` live in a registry the WHERE accessor doesn't see). + +**Side-effect: trait gating becomes uniform across surfaces.** Once both family-registry consumers source from one registry, `fns.eq` and `column.eq` reach the same descriptor, and a codec that opts out of `equality` is unreachable on both. Today's asymmetry (cipherstash columns lose `.eq` on the ORM but `fns.eq` still accepts them) goes away. This is a deliberate behaviour change, not an accident of the refactor. + +**Extensions remain unchanged.** Extension `queryOperations()` factories already produce `SqlOperationDescriptor` entries with the right shape. They register alongside the family operations and dispatch identically. The "is it a built-in or an extension" distinction stops being a code-path distinction and becomes a registration-time provenance distinction (who registered the descriptor: the family, an adapter, or an extension). + +**Supersedes two ADR carve-outs via a new ADR.** ADR 203 ("Non-goals: Migration of built-in comparisons to trait-targeted operations") and ADR 206 ("Non-goals: Changing the built-in comparison methods") explicitly excluded this work. Both carve-outs are now reversed. The project's close-out drafts a new ADR that records the unified-registry decision and supersedes the two non-goal lines in ADR 203 and ADR 206 by reference. The positive technical content of both ADRs (trait-targeted `self`, operations-as-TypeScript-functions) survives unchanged and is built upon by the new ADR. + +# Requirements + +## Functional Requirements + +### SQL family operation registry + +- **FR1.** The SQL family ships a `queryOperations()` factory (matching the ADR 206 contributor shape) in the existing `@prisma-next/family-sql` package (`packages/2-sql/9-family/`). The factory registers every operation currently defined in `BuiltinFunctions` and the **predicate / value-returning** operations from `COMPARISON_METHODS_META` (`eq`, `neq`, `in`, `notIn`, `gt`, `lt`, `gte`, `lte`, `like`, `isNull`, `isNotNull`, plus `and`, `or`, `exists`, `notExists` from `BuiltinFunctions`). `asc` and `desc` are **not** part of this registry — see FR18. The factory is generic over the contract's codec-types map. No new package is added. +- **FR2.** The contract-assembly layer calls the family `queryOperations()` factory the same way it calls adapter and extension factories. Family operations land in the same `SqlOperationRegistry` instance carried by `ExecutionContext.queryOperations`. +- **FR3.** Family operation entries declare `self` as trait-targeted (`{ traits: [...] }`) where the operation's reachability on a column is determined by codec capability today. The trait sets match the existing `COMPARISON_METHODS_META` entries: `eq`/`neq`/`in`/`notIn` → `['equality']`; `gt`/`lt`/`gte`/`lte` → `['order']`; `like` → `['textual']`. +- **FR4.** Family operations that apply regardless of codec on the sql-builder surface but are not reachable as column methods (`and`, `or`, `exists`, `notExists`) are registered with no `self`. The sql-builder's `fns` Proxy exposes them; the ORM column accessor does not surface them as column methods. +- **FR5.** Null-check operations (`isNull`, `isNotNull`) remain reachable as column methods on every codec. They are registered with `self: { any: true }` — a new third arm of the `SelfSpec` discriminated union that means "applies to every codec, regardless of trait set." Concretely: + - `SelfSpec` in `packages/1-framework/1-core/operations/src/index.ts` gains a third member: `{ readonly any: true; readonly codecId?: never; readonly traits?: never }`. The existing `codecId | traits` discriminated-union mutual exclusion extends to `any` (you set exactly one of the three). + - The registration validator in `createOperationRegistry` (`packages/1-framework/1-core/operations/src/index.ts:42-50`) accepts the new arm: exactly one of `codecId`, `traits`, `any` must be set when `self` is present. + - The ORM model accessor's resolution loop (`packages/3-extensions/sql-orm-client/src/model-accessor.ts:71-83`) gains a branch: when `self.any` is `true`, the op is indexed under every codec in the registry. + - The type-level matcher `OpMatchesField` in `packages/3-extensions/sql-orm-client/src/types.ts:224-238` gains a clause: `Self extends { readonly any: true }` returns `true` for any field. + - Every other consumer that switches on the existing `codecId | traits` discrimination (registry-driven types, contract-emit codepaths, debug printers) must be updated to handle the third arm. `pnpm typecheck` is the safety net. + - The user-visible surface does not regress: every column type that has `.isNull()` today continues to have it. + +### ORM ordering-operations registry + +- **FR18.** The ORM client (`@prisma-next/sql-orm-client`) ships its **own** operation registry containing `asc` and `desc`. The registry uses the same `SqlOperationDescriptor` shape and the same trait-targeted matching primitive as the SQL family registry (so `asc`/`desc` declare `self: { traits: ['order'] }` and trait-expand at registry construction time, identical to the family-side mechanism). The registry is constructed inside the ORM, not contributed by the SQL family or by the contract. +- **FR19.** The ORM ordering registry is **not** wired into the contract's `descriptorMeta.types.queryOperationTypes`. `asc`/`desc` do not appear in the generated contract's `QueryOperationTypes` alias, are not visible to the sql-builder, and are not surfaced on the WHERE/HAVING/column accessor. Their visibility is limited to the orderBy callback accessor (see FR21). +- **FR20.** Extension contributions to the ORM ordering registry are out of scope. The ORM ordering registry is closed at this layer: only `asc`/`desc` are registered. A future project may introduce a contributor slot for extension-defined ordering primitives (e.g. `ascNullsFirst`); this project does not. + +### Accessor surfaces and return-type filtering + +- **FR21.** The ORM's accessor synthesis uses two independent registry consultations: + - The **WHERE-style column accessor** (used in `where`, `having`, and other predicate contexts) sources operations from the SQL family registry filtered by *(a)* `self` matches the column's codec (existing trait-expansion logic), *and (b)* return codec carries the `boolean` trait (predicate-return filter). `asc`/`desc` cannot appear on this accessor because the ORM ordering registry is not consulted by this code path. + - The **orderBy callback accessor** sources operations from the ORM ordering registry filtered by `self`-trait match against the column's codec. The SQL family registry is not consulted by this code path. The accessor exposes only the column's `OrderByItem`-returning methods (today: `asc`, `desc`). +- **FR22.** The **HAVING aggregate selector** sources operations from the SQL family registry filtered by *(a)* `self`-trait match against the aggregate's return codec (e.g. `pg/int8@1` for `count`, the column's codec for `sum`/`avg`/`min`/`max`), *and (b)* return codec carries the `boolean` trait. The hand-listed `HavingComparisonMethods = Pick, 'eq' | 'neq' | 'gt' | 'lt' | 'gte' | 'lte'>` is **deleted**; the HAVING method surface is derived from the registry by the predicate-return rule. The net set on numeric aggregates becomes `eq | neq | in | notIn | gt | lt | gte | lte | isNull | isNotNull` (`like` is excluded by the textual-trait gate on numeric codecs). This is a deliberate, documented widening of the HAVING surface. +- **FR23.** The **sql-builder `fns` proxy** sources operations from the SQL family registry only. `asc`/`desc` are not visible on `fns` (they live in a registry `fns` does not consult). This preserves today's `fns` surface — `fns.asc` does not typecheck before or after this project. + +### Emitter wiring for family operation types + +- **FR15.** The SQL family contributes a `descriptorMeta` entry with `types.queryOperationTypes` so the emitter's existing alias-aggregation step (`packages/2-sql/3-tooling/emitter/src/index.ts:332-342`) lifts the family's operation types into the generated contract's `QueryOperationTypes` alias. Today only adapter/extension `descriptorMeta` carries this slot (e.g. `packages/3-targets/6-adapters/postgres/src/core/descriptor-meta.ts:278-284`); the family does not. After this project the family does, alongside a published `QueryOperationTypes` type matching the runtime factory. +- **FR16.** Authored `impl` functions for `in` / `notIn` (and any other operation whose user-facing signature needs more than a single arrow type) are free to use TypeScript overloads. ADR 206 explicitly permits this. + +### Binary operator signatures + +- **FR17.** Binary trait-gated operators (`eq`, `neq`, `gt`, `gte`, `lt`, `lte`, `in`, `notIn`) are authored with a trait-constrained codec-id generic that ties both operands to the same codec id. The generic is constrained to the union of codec ids in `CT` whose declared traits contain the operation's required trait — i.e., the same "inverse trait → codec ids" resolution that ADR 203 already describes for `fns.ilike` ([ADR 203](../../docs/architecture%20docs/adrs/ADR%20203%20-%20Trait-targeted%20operation%20arguments.md), "How matching works"). The user-visible signature shape is: + + ```ts + // Illustrative — helper name is the implementer's choice. + // `EqualityCodecId` resolves to the union of codec ids in CT + // whose trait set contains 'equality'. + impl: >( + a: CodecExpression | null, + b: CodecExpression | null, + ) => Expression + ``` + + Two consequences flow from this: + - **Trait gating is in the signature.** Calling `fns.eq` on a codec without the required trait fails type-checking at the call site, not at a downstream derivation. This is how the cipherstash tightening (FR9 / AC3) is realised on `fns`. + - **Operands stay symmetric.** Both `a` and `b` must share the inferred codec id, matching today's `BuiltinFunctions['eq']` ergonomics. The implementer must not write asymmetric signatures (e.g. `TraitExpression<...>` for `a`, free `CodecExpression` for `b`) — they'd allow `fns.eq(textCol, intCol)`-style calls to typecheck. + +### Removal of legacy surfaces + +- **FR6.** `COMPARISON_METHODS_META` in `packages/3-extensions/sql-orm-client/src/types.ts` is deleted. Every reader — the model accessor's built-in loop at line 141, the extension-method return-type wrapper at line 182, the `HavingComparisonMethods` type at line 514 — is updated to source operations from the SQL family registry (for predicates) or the ORM ordering registry (for `asc`/`desc`), filtered by the return-shape rules in FR21–FR23. `HavingComparisonMethods` is deleted outright (FR22); its replacement is derived structurally. +- **FR7.** `BuiltinFunctions` in `packages/2-sql/4-lanes/sql-builder/src/expression.ts` is deleted. `Functions` no longer references it. `createBuiltinFunctions()` in `runtime/functions.ts` is deleted; the Proxy in `createFunctions` performs a single registry lookup. +- **FR8.** Aggregate-only functions (`count`, `sum`, `avg`, `min`, `max`) are **out of scope** for this project. `AggregateOnlyFunctions` and `createAggregateOnlyFunctions` remain hardcoded in sql-builder. See Non-goals. + +### Trait gating becomes uniform + +- **FR9.** After this project lands, the sql-builder `fns.eq`, `fns.ne`, `fns.gt`, etc. only accept expressions whose codec declares the operation's required traits. A codec with `traits: []` (e.g., cipherstash) is rejected by `fns.eq` at the type level, matching the ORM model accessor's behaviour today. +- **FR10.** The ORM model accessor's per-column method synthesis (`createScalarFieldAccessor`) becomes a single loop over the registry's per-codec index — no separate `COMPARISON_METHODS_META` loop. + +### Backward-compat policy + +- **FR11.** No backward-compat shims. Every consumer of `COMPARISON_METHODS_META`, `BuiltinFunctions`, `createBuiltinFunctions`, or any name re-exported from those — inside the repo or in the demo/examples — is updated in the same change. Type imports for the removed types are removed, not re-routed. + +### Adapter/extension impact + +- **FR12.** Adapters and extensions that today register operations via `queryOperations()` are not modified by this project (they already use the registry pattern). If a name collision is introduced (e.g., an extension named one of its operations `eq` before this project), the project surfaces the collision as a build error and the extension is renamed — no priority/precedence policy is introduced. + +### Type-level surface + +- **FR13.** The ORM column accessor's `ComparisonMethods` type is preserved as the public-facing wrapper for non-predicate operation return types. Its filtering logic (only expose method `K` if the column's traits include the meta's required traits) is sourced from the registry instead of `COMPARISON_METHODS_META`. The published type expressivity (e.g. `eq` accepts `T | null`, returns `Expression`) is preserved. +- **FR14.** The sql-builder `Functions` type composes purely from `DeriveExtFunctions` — i.e., from the contract's `queryOperationTypes` map. Because the contract's `queryOperationTypes` now includes the family operations, the user-visible callable set on `fns` is the same union as today. + +## Non-Functional Requirements + +- **NFR1. Registration cost.** Trait-targeted operation expansion at registry assembly is O(operations × codecs); it runs once at `ExecutionContext` construction. The added work for the family operations (≈13 trait-targeted entries × N codecs) must not measurably regress execution-context construction time on the existing benchmarks. Field access on column accessors must remain a single map lookup ([ADR 203](../../docs/architecture%20docs/adrs/ADR%20203%20-%20Trait-targeted%20operation%20arguments.md) hot-path argument). +- **NFR2. Type-check time.** The contract's `queryOperationTypes` map gains ≈17 new entries. The resulting `Functions` type and `ComparisonMethods` derivation must not measurably regress type-check time on the demo or the existing test suite. If it does, the implementer must investigate (e.g., shared `infer` slots, distributive conditional types) rather than ship a regression. +- **NFR3. Bundle size.** Removing `COMPARISON_METHODS_META`, `BuiltinFunctions`, and `createBuiltinFunctions()` while adding the equivalent registry entries should be size-neutral or smaller. The implementer should not introduce a parallel re-export layer that defeats this. + +## Non-goals + +- **Aggregate functions** (`count`, `sum`, `avg`, `min`, `max`). They live in a separate `AggregateOnlyFunctions` hardcoded list. Migrating them is a natural phase 2 but is excluded here because (a) they are not column methods, (b) they have different scoping rules (only available inside `groupBy`/`having`), and (c) extending the registry to express aggregate-only availability would broaden this project's design surface. The same rationale applies to anything that today depends on aggregate-only scope (`HavingBuilder`). +- **Document/Mongo family.** No document-family operation registry exists today. Introducing one is out of scope. This project unifies SQL-family operations only. +- **Operation-name collision policy.** If an extension previously named one of its operations the same as a soon-to-be-registered family operation, the build will fail. This project does not introduce a precedence rule, an override mechanism, or a "shadowing" warning — collisions are an authoring error and are renamed at registration. +- **A common-vs-family operation distinction at the registry level.** The registry remains flat. Family-registered operations are not flagged differently from extension-registered ones beyond their registration site. +- **Schema/contract format changes.** The contract's `queryOperationTypes` shape, the descriptor shape, and the registry's `register(name, descriptor)` API are unchanged. Only registration sites change. +- **Migration of `OrderByItem` / `NullCheckExpr` AST nodes.** Built-in operations are migrated as authored functions that emit the same AST nodes they emit today. AST changes are out of scope. + +# Acceptance Criteria + +- [ ] **AC1. The legacy surfaces are gone.** A repo-wide search for `COMPARISON_METHODS_META`, `BuiltinFunctions`, and `createBuiltinFunctions` finds no production references. Covers FR6, FR7. +- [ ] **AC2. The family registers operations through the standard contributor surface.** The SQL family package exposes a `queryOperations()` factory that returns descriptors for every operation previously hardcoded. The contract-assembly site that aggregates extension operations also reads this factory; no separate code path is added for family operations. Covers FR1, FR2. +- [ ] **AC3. Trait gating is symmetric.** A test that calls `fns.eq(cipherstashColumn, cipherstashColumn)` fails type-checking, mirroring the ORM model accessor's existing behaviour for the same column. A symmetric test on a codec that declares the relevant trait (e.g., `pg/text@1` for `like`) typechecks on both surfaces. Covers FR3, FR9. +- [ ] **AC4. Per-column ORM method surface is unchanged.** For every codec in the test contracts (pg core codecs + cipherstash + arktype-json + any pg/vector-like codec), the set of comparison methods exposed on its ORM column accessor is identical before and after this project, modulo the cipherstash `.eq`/`.in` behaviour change called out in AC3 (which already matched ORM behaviour today — no regression). Covers FR10, FR13. +- [ ] **AC5. `fns` surface is callable for the same names as today.** Every `fns.` call that was valid before this project (e.g., `fns.eq`, `fns.and`, `fns.exists`, `fns.notIn`, `fns.ilike`) is still valid afterwards, with the trait-tightening from AC3 the only intentional difference. Covers FR4, FR7, FR14. +- [ ] **AC6. `isNull`/`isNotNull` reachable everywhere via `self: { any: true }`.** The `isNull`/`isNotNull` family-registry entries declare `self: { any: true }`. Every column type that today has `.isNull()`/`.isNotNull()` continues to. A test against a codec with empty traits (e.g., cipherstash) confirms this. A registration test confirms that omitting `self` entirely (no `codecId`, no `traits`, no `any`) still throws, and that setting more than one of the three throws. Covers FR5. +- [ ] **AC7. No backward-compat shims.** No file in the repo re-exports any of the removed names, no deprecated alias is introduced, and the demo/examples are updated in the same change. `pnpm lint:deps` passes. Covers FR11. +- [ ] **AC8. HAVING surface is derived, not hand-listed.** `HavingComparisonMethods` is deleted. The HAVING method set on aggregate selectors is derived from the SQL family registry by the predicate-return filter (FR22). A type-level test on a numeric aggregate (e.g. `sum(intField)`) demonstrates the new surface: `eq | neq | in | notIn | gt | lt | gte | lte | isNull | isNotNull` available, `like` not available (textual trait gate). A type-level test on the same aggregate confirms `.asc()` / `.desc()` are not callable in HAVING (they live in the ORM ordering registry, which HAVING does not consult). Covers FR6 (transitive), FR22. +- [ ] **AC9. End-to-end ORM query still builds and emits correct SQL.** The existing query-build integration tests (predicates on `where`, ordering, null checks, `in` with lists and with subqueries) pass with no modification. SQL output is byte-identical to before the project, since the underlying AST nodes are unchanged. Covers FR1–FR10 indirectly. +- [ ] **AC10. New ADR supersedes the ADR 203 / ADR 206 carve-outs.** A new ADR is drafted at close-out that records the unified-registry decision and explicitly supersedes the "Migration of built-in comparisons …" and "Changing the built-in comparison methods" non-goal lines in ADR 203 and ADR 206. Both prior ADRs gain a "Superseded in part by ADR NNN" note pointing at the new ADR. Covers project hygiene; not strictly a code AC. +- [ ] **AC11. Family contract emission picks up family operation types.** The emitted `contract.d.ts` for a contract whose family is `sql` contains the family's operation types intersected into the `QueryOperationTypes` alias (alongside any adapter/extension types). A round-trip test (emit + typecheck + call `fns.eq`/`column.eq` on a generated contract) confirms the chain works end-to-end. The emitted `QueryOperationTypes` does **not** contain `asc`/`desc` (they're ORM-private, FR19). Covers FR15, FR19. +- [ ] **AC12. Binary operator signatures gate by trait and tie operands.** Type-level tests demonstrate: (a) `fns.eq(intCol, intCol)` typechecks; (b) `fns.eq(textCol, intCol)` fails type-checking (codec ids don't match); (c) `fns.eq(cipherstashCol, cipherstashCol)` fails type-checking (codec lacks `equality` trait). Same trio for `gt` / `lt` / `gte` / `lte` against the `order` trait and for `like` against the `textual` trait. Covers FR17 (and reinforces FR9 / AC3). +- [ ] **AC13. orderBy / WHERE accessor split.** Type-level tests demonstrate: (a) inside an `orderBy` callback, `m.intField.asc()` and `m.intField.desc()` typecheck and return `OrderByItem`; (b) inside a `where` callback, `m.intField.asc` is not present on the column accessor (property access fails type-check, not "method returns wrong type"); (c) `fns.asc` is not callable from the sql-builder surface (property does not exist). Covers FR18, FR19, FR21, FR23. + +# Other Considerations + +## Security + +No impact. This is an internal refactor; the user-visible authoring surface stays the same (modulo the symmetry tightening for cipherstash). No new data crosses a trust boundary. + +## Cost + +No impact. No new infrastructure, no new runtime dependencies. + +## Observability + +No impact. Registry assembly already happens during execution-context construction; no new metrics are warranted. If type-check time regresses noticeably (NFR2), the implementer should investigate and either resolve or surface a blocker — there is no observability surface for type-check time. + +## Data Protection + +No impact. No personal data is involved. + +## Analytics + +No impact. + +# References + +- [ADR 202 — Codec trait system](../../docs/architecture%20docs/adrs/ADR%20202%20-%20Codec%20trait%20system.md) — defines `CodecTrait` and codec trait declarations. +- [ADR 203 — Trait-targeted operation arguments](../../docs/architecture%20docs/adrs/ADR%20203%20-%20Trait-targeted%20operation%20arguments.md) — introduces `self: { traits }`. This project supersedes its non-goal carve-out for built-ins. +- [ADR 206 — Operations as TypeScript functions](../../docs/architecture%20docs/adrs/ADR%20206%20-%20Operations%20as%20TypeScript%20functions.md) — author shape for operations + `QueryOperationTypes` factory pattern. This project supersedes its non-goal carve-out for built-ins. +- `packages/3-extensions/sql-orm-client/src/types.ts:325-378` — `COMPARISON_METHODS_META` (to delete). +- `packages/3-extensions/sql-orm-client/src/model-accessor.ts:60-156` — model accessor's two-loop synthesis (to collapse to one). +- `packages/2-sql/4-lanes/sql-builder/src/expression.ts:62-117` — `BuiltinFunctions` and `Functions` (to delete / simplify). +- `packages/2-sql/4-lanes/sql-builder/src/runtime/functions.ts:153-211` — `createBuiltinFunctions` and `createFunctions` (to delete / collapse). +- `packages/2-sql/1-core/operations/src/index.ts` — `SqlOperationRegistry`, `SqlOperationDescriptor` (target home for built-in registrations through the family factory). +- `packages/2-sql/9-family/` — `@prisma-next/family-sql` package; default home for the new `queryOperations()` factory. + +# Open Questions + +_All resolved. Remaining items are implementer degrees of freedom captured inline in the relevant FRs (e.g. specific helper names like `EqualityCodecId`, internal AST node reuse vs. new factories)._ diff --git a/test/e2e/framework/test/fixtures/generated/contract.d.ts b/test/e2e/framework/test/fixtures/generated/contract.d.ts index 6f287c3ad9..7ec2035eb9 100644 --- a/test/e2e/framework/test/fixtures/generated/contract.d.ts +++ b/test/e2e/framework/test/fixtures/generated/contract.d.ts @@ -8,6 +8,7 @@ import type { Vector, } from '@prisma-next/extension-pgvector/codec-types'; import type { QueryOperationTypes as PgVectorQueryOperationTypes } from '@prisma-next/extension-pgvector/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -45,7 +46,8 @@ export type ProfileHash = export type CodecTypes = PgTypes & PgVectorTypes & ArktypeJsonTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps & +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps & PgVectorQueryOperationTypes; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] diff --git a/test/e2e/framework/test/sqlite/fixtures/generated/contract.d.ts b/test/e2e/framework/test/sqlite/fixtures/generated/contract.d.ts index e33f069157..5b8614668f 100644 --- a/test/e2e/framework/test/sqlite/fixtures/generated/contract.d.ts +++ b/test/e2e/framework/test/sqlite/fixtures/generated/contract.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated by 'prisma-next contract emit'. // To regenerate, run: prisma-next contract emit import type { CodecTypes as SqliteTypes } from '@prisma-next/adapter-sqlite/codec-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { ContractWithTypeMaps, @@ -24,7 +25,7 @@ export type ProfileHash = export type CodecTypes = SqliteTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = Record; +export type QueryOperationTypes = SqlFamilyQueryOperationTypes; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/test/e2e/framework/test/sqlite/raw-sql.test.ts b/test/e2e/framework/test/sqlite/raw-sql.test.ts index 811f3737fe..82927d59d6 100644 --- a/test/e2e/framework/test/sqlite/raw-sql.test.ts +++ b/test/e2e/framework/test/sqlite/raw-sql.test.ts @@ -7,6 +7,7 @@ import { sqliteRawCodecInferer } from '@prisma-next/adapter-sqlite/adapter'; import sqliteAdapter from '@prisma-next/adapter-sqlite/runtime'; import sqliteDriver from '@prisma-next/driver-sqlite/runtime'; import { SqlContractSerializer } from '@prisma-next/family-sql/ir'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; import { sql } from '@prisma-next/sql-builder/runtime'; import type { Db } from '@prisma-next/sql-builder/types'; @@ -76,6 +77,7 @@ async function buildHarness(middleware?: readonly SqlMiddleware[]): Promise { rawDb.close(); const stack = createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: sqliteTarget, adapter: sqliteAdapter, driver: sqliteDriver, diff --git a/test/e2e/framework/test/sqlite/utils.ts b/test/e2e/framework/test/sqlite/utils.ts index 5bb5881bc1..ef0c3ef490 100644 --- a/test/e2e/framework/test/sqlite/utils.ts +++ b/test/e2e/framework/test/sqlite/utils.ts @@ -6,6 +6,7 @@ import sqliteAdapter from '@prisma-next/adapter-sqlite/runtime'; import type { Contract } from '@prisma-next/contract/types'; import sqliteDriver from '@prisma-next/driver-sqlite/runtime'; import { SqlContractSerializer } from '@prisma-next/family-sql/ir'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; import { sql as sqlBuilder } from '@prisma-next/sql-builder/runtime'; import type { Db } from '@prisma-next/sql-builder/types'; @@ -183,6 +184,7 @@ async function createSqliteRuntime>( rawCodecInferer: RawCodecInferer; }> { const stack = createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: sqliteTarget, adapter: sqliteAdapter, driver: sqliteDriver, diff --git a/test/integration/test/cli-journeys/data-transform-enum-rebuild.e2e.test.ts b/test/integration/test/cli-journeys/data-transform-enum-rebuild.e2e.test.ts index 00fe29ab4d..60dbf7398d 100644 --- a/test/integration/test/cli-journeys/data-transform-enum-rebuild.e2e.test.ts +++ b/test/integration/test/cli-journeys/data-transform-enum-rebuild.e2e.test.ts @@ -110,6 +110,7 @@ withTempDir(({ createTempDir }) => { const dbSetupBlock = [ `import postgresAdapter from '@prisma-next/adapter-postgres/runtime';`, + `import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime';`, `import { sql } from '@prisma-next/sql-builder/runtime';`, `import { createExecutionContext, createSqlExecutionStack } from '@prisma-next/sql-runtime';`, `import postgresTarget from '@prisma-next/target-postgres/runtime';`, @@ -117,7 +118,7 @@ withTempDir(({ createTempDir }) => { 'const db = sql({', ' context: createExecutionContext({', ' contract: endContract,', - ' stack: createSqlExecutionStack({ target: postgresTarget, adapter: postgresAdapter }),', + ' stack: createSqlExecutionStack({ family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter }),', ' }),', '});', '', diff --git a/test/integration/test/cli-journeys/data-transform-not-null-backfill.e2e.test.ts b/test/integration/test/cli-journeys/data-transform-not-null-backfill.e2e.test.ts index 6616130568..723e724c62 100644 --- a/test/integration/test/cli-journeys/data-transform-not-null-backfill.e2e.test.ts +++ b/test/integration/test/cli-journeys/data-transform-not-null-backfill.e2e.test.ts @@ -93,6 +93,7 @@ withTempDir(({ createTempDir }) => { const dbSetupBlock = [ `import postgresAdapter from '@prisma-next/adapter-postgres/runtime';`, + `import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime';`, `import { sql } from '@prisma-next/sql-builder/runtime';`, `import { createExecutionContext, createSqlExecutionStack } from '@prisma-next/sql-runtime';`, `import postgresTarget from '@prisma-next/target-postgres/runtime';`, @@ -100,7 +101,7 @@ withTempDir(({ createTempDir }) => { 'const db = sql({', ' context: createExecutionContext({', ' contract: endContract,', - ' stack: createSqlExecutionStack({ target: postgresTarget, adapter: postgresAdapter }),', + ' stack: createSqlExecutionStack({ family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter }),', ' }),', '});', '', diff --git a/test/integration/test/cli-journeys/data-transform-nullable-tightening.e2e.test.ts b/test/integration/test/cli-journeys/data-transform-nullable-tightening.e2e.test.ts index 5ccc050f03..e914228cf8 100644 --- a/test/integration/test/cli-journeys/data-transform-nullable-tightening.e2e.test.ts +++ b/test/integration/test/cli-journeys/data-transform-nullable-tightening.e2e.test.ts @@ -103,6 +103,7 @@ withTempDir(({ createTempDir }) => { const dbSetupBlock = [ `import postgresAdapter from '@prisma-next/adapter-postgres/runtime';`, + `import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime';`, `import { sql } from '@prisma-next/sql-builder/runtime';`, `import { createExecutionContext, createSqlExecutionStack } from '@prisma-next/sql-runtime';`, `import postgresTarget from '@prisma-next/target-postgres/runtime';`, @@ -110,7 +111,7 @@ withTempDir(({ createTempDir }) => { 'const db = sql({', ' context: createExecutionContext({', ' contract: endContract,', - ' stack: createSqlExecutionStack({ target: postgresTarget, adapter: postgresAdapter }),', + ' stack: createSqlExecutionStack({ family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter }),', ' }),', '});', '', diff --git a/test/integration/test/cli-journeys/data-transform-type-change.e2e.test.ts b/test/integration/test/cli-journeys/data-transform-type-change.e2e.test.ts index 5655de924d..4a23398d4a 100644 --- a/test/integration/test/cli-journeys/data-transform-type-change.e2e.test.ts +++ b/test/integration/test/cli-journeys/data-transform-type-change.e2e.test.ts @@ -103,6 +103,7 @@ withTempDir(({ createTempDir }) => { const dbSetupBlock = [ `import postgresAdapter from '@prisma-next/adapter-postgres/runtime';`, + `import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime';`, `import { sql } from '@prisma-next/sql-builder/runtime';`, `import { createExecutionContext, createSqlExecutionStack } from '@prisma-next/sql-runtime';`, `import postgresTarget from '@prisma-next/target-postgres/runtime';`, @@ -110,7 +111,7 @@ withTempDir(({ createTempDir }) => { 'const db = sql({', ' context: createExecutionContext({', ' contract: endContract,', - ' stack: createSqlExecutionStack({ target: postgresTarget, adapter: postgresAdapter }),', + ' stack: createSqlExecutionStack({ family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter }),', ' }),', '});', '', diff --git a/test/integration/test/cli-journeys/invariant-routing.e2e.test.ts b/test/integration/test/cli-journeys/invariant-routing.e2e.test.ts index 5243fb2d85..9f07a66397 100644 --- a/test/integration/test/cli-journeys/invariant-routing.e2e.test.ts +++ b/test/integration/test/cli-journeys/invariant-routing.e2e.test.ts @@ -75,6 +75,7 @@ function patchBackfillMigrationTs( const dbSetupBlock = [ `import postgresAdapter from '@prisma-next/adapter-postgres/runtime';`, + `import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime';`, `import { sql } from '@prisma-next/sql-builder/runtime';`, `import { createExecutionContext, createSqlExecutionStack } from '@prisma-next/sql-runtime';`, `import postgresTarget from '@prisma-next/target-postgres/runtime';`, @@ -82,7 +83,7 @@ function patchBackfillMigrationTs( 'const db = sql({', ' context: createExecutionContext({', ' contract: endContract,', - ' stack: createSqlExecutionStack({ target: postgresTarget, adapter: postgresAdapter }),', + ' stack: createSqlExecutionStack({ family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter }),', ' }),', '});', '', @@ -600,6 +601,7 @@ withTempDir(({ createTempDir }) => { expect(draftManifest.to).toBe(c1Hash); const handAuthored = `import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { Migration, MigrationCLI } from '@prisma-next/postgres/migration'; import postgresTarget from '@prisma-next/target-postgres/runtime'; import { sql } from '@prisma-next/sql-builder/runtime'; @@ -609,7 +611,7 @@ import endContract from './end-contract.json' with { type: 'json' }; const db = sql({ context: createExecutionContext({ contract: endContract, - stack: createSqlExecutionStack({ target: postgresTarget, adapter: postgresAdapter }), + stack: createSqlExecutionStack({ family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter }), }), }); @@ -622,8 +624,8 @@ export default class M extends Migration { return [ this.dataTransform(endContract, 'normalize-user-email', { invariantId: 'normalize-user-email', - check: () => db.user.select('id').where((f, fns) => fns.ne(f.email, '${NORMALIZED_EMAIL}')).limit(1), - run: () => db.user.update({ email: '${NORMALIZED_EMAIL}' }).where((f, fns) => fns.ne(f.email, '${NORMALIZED_EMAIL}')), + check: () => db.user.select('id').where((f, fns) => fns.neq(f.email, '${NORMALIZED_EMAIL}')).limit(1), + run: () => db.user.update({ email: '${NORMALIZED_EMAIL}' }).where((f, fns) => fns.neq(f.email, '${NORMALIZED_EMAIL}')), }), ]; } @@ -749,6 +751,7 @@ MigrationCLI.run(import.meta.url, M); ); const handAuthored = `import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { Migration, MigrationCLI } from '@prisma-next/postgres/migration'; import postgresTarget from '@prisma-next/target-postgres/runtime'; import { sql } from '@prisma-next/sql-builder/runtime'; @@ -758,7 +761,7 @@ import endContract from './end-contract.json' with { type: 'json' }; const db = sql({ context: createExecutionContext({ contract: endContract, - stack: createSqlExecutionStack({ target: postgresTarget, adapter: postgresAdapter }), + stack: createSqlExecutionStack({ family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter }), }), }); @@ -771,8 +774,8 @@ export default class M extends Migration { return [ this.dataTransform(endContract, '${SELF_EDGE_INVARIANT}', { invariantId: '${SELF_EDGE_INVARIANT}', - check: () => db.user.select('id').where((f, fns) => fns.ne(f.email, '${NORMALIZED_EMAIL}')).limit(1), - run: () => db.user.update({ email: '${NORMALIZED_EMAIL}' }).where((f, fns) => fns.ne(f.email, '${NORMALIZED_EMAIL}')), + check: () => db.user.select('id').where((f, fns) => fns.neq(f.email, '${NORMALIZED_EMAIL}')).limit(1), + run: () => db.user.update({ email: '${NORMALIZED_EMAIL}' }).where((f, fns) => fns.neq(f.email, '${NORMALIZED_EMAIL}')), }), ]; } diff --git a/test/integration/test/cli-journeys/migration-round-trip.e2e.test.ts b/test/integration/test/cli-journeys/migration-round-trip.e2e.test.ts index f7ff6d5023..46086dc32d 100644 --- a/test/integration/test/cli-journeys/migration-round-trip.e2e.test.ts +++ b/test/integration/test/cli-journeys/migration-round-trip.e2e.test.ts @@ -117,6 +117,7 @@ withTempDir(({ createTempDir }) => { const migrationTs = ` import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { sql } from '@prisma-next/sql-builder/runtime'; import { createExecutionContext, createSqlExecutionStack } from '@prisma-next/sql-runtime'; import { Migration, MigrationCLI, addColumn, setNotNull } from '@prisma-next/postgres/migration'; @@ -126,7 +127,7 @@ import endContract from './end-contract.json' with { type: 'json' }; const db = sql({ context: createExecutionContext({ contract: endContract, - stack: createSqlExecutionStack({ target: postgresTarget, adapter: postgresAdapter }), + stack: createSqlExecutionStack({ family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter }), }), }); diff --git a/test/integration/test/cross-package/middleware-cache.test.ts b/test/integration/test/cross-package/middleware-cache.test.ts index 9971dfb42f..35d83eab35 100644 --- a/test/integration/test/cross-package/middleware-cache.test.ts +++ b/test/integration/test/cross-package/middleware-cache.test.ts @@ -2,6 +2,7 @@ import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; import postgresDriver from '@prisma-next/driver-postgres/runtime'; import pgvector from '@prisma-next/extension-pgvector/runtime'; import { SqlContractSerializer } from '@prisma-next/family-sql/ir'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { type ExecutionStackInstance, instantiateExecutionStack, @@ -153,6 +154,7 @@ describe('integration: middleware-cache against real Postgres', { }); const stack = createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter, driver: { diff --git a/test/integration/test/fixtures/contract.d.ts b/test/integration/test/fixtures/contract.d.ts index d3ed325f6c..faf46206c3 100644 --- a/test/integration/test/fixtures/contract.d.ts +++ b/test/integration/test/fixtures/contract.d.ts @@ -2,6 +2,7 @@ // This file is automatically generated by 'prisma-next contract emit'. // To regenerate, run: prisma-next contract emit import type { QueryOperationTypes as PgAdapterQueryOps } from '@prisma-next/adapter-postgres/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -38,7 +39,8 @@ export type ProfileHash = export type CodecTypes = PgTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps; +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] : _Encoded; diff --git a/test/integration/test/rewriting-middleware.integration.test.ts b/test/integration/test/rewriting-middleware.integration.test.ts index 4c56795976..b26395bf69 100644 --- a/test/integration/test/rewriting-middleware.integration.test.ts +++ b/test/integration/test/rewriting-middleware.integration.test.ts @@ -2,6 +2,7 @@ import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; import postgresDriver from '@prisma-next/driver-postgres/runtime'; import pgvector from '@prisma-next/extension-pgvector/runtime'; import { SqlContractSerializer } from '@prisma-next/family-sql/ir'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { type ExecutionStackInstance, instantiateExecutionStack, @@ -123,6 +124,7 @@ describe('integration: SQL middleware rewriting', { timeout: timeouts.databaseOp }); const stack = createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter, driver: { diff --git a/test/integration/test/runtime.verify-marker.missing-table.integration.test.ts b/test/integration/test/runtime.verify-marker.missing-table.integration.test.ts index 0ec0275dcc..2c39977516 100644 --- a/test/integration/test/runtime.verify-marker.missing-table.integration.test.ts +++ b/test/integration/test/runtime.verify-marker.missing-table.integration.test.ts @@ -2,6 +2,7 @@ import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; import postgresDriver from '@prisma-next/driver-postgres/runtime'; import pgvector from '@prisma-next/extension-pgvector/runtime'; import { SqlContractSerializer } from '@prisma-next/family-sql/ir'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; import { sql } from '@prisma-next/sql-builder/runtime'; import type { Log } from '@prisma-next/sql-runtime'; @@ -62,6 +63,7 @@ describe('runtime verify-marker: missing marker table', { const log = { info: vi.fn(), warn: vi.fn(), error: vi.fn() } satisfies Log; const stack = createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter, driver: postgresDriver, diff --git a/test/integration/test/sql-builder/fixtures/generated/contract.d.ts b/test/integration/test/sql-builder/fixtures/generated/contract.d.ts index 37ef4dbe5b..dd6340b419 100644 --- a/test/integration/test/sql-builder/fixtures/generated/contract.d.ts +++ b/test/integration/test/sql-builder/fixtures/generated/contract.d.ts @@ -7,6 +7,7 @@ import type { Vector, } from '@prisma-next/extension-pgvector/codec-types'; import type { QueryOperationTypes as PgVectorQueryOperationTypes } from '@prisma-next/extension-pgvector/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -44,7 +45,8 @@ export type ProfileHash = export type CodecTypes = PgTypes & PgVectorTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps & +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps & PgVectorQueryOperationTypes; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] diff --git a/test/integration/test/sql-builder/raw-sql.integration.test.ts b/test/integration/test/sql-builder/raw-sql.integration.test.ts index ec279660b1..07bef7a2c7 100644 --- a/test/integration/test/sql-builder/raw-sql.integration.test.ts +++ b/test/integration/test/sql-builder/raw-sql.integration.test.ts @@ -3,6 +3,7 @@ import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; import postgresDriver from '@prisma-next/driver-postgres/runtime'; import pgvector from '@prisma-next/extension-pgvector/runtime'; import { SqlContractSerializer } from '@prisma-next/family-sql/ir'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { type ExecutionStackInstance, instantiateExecutionStack, @@ -110,6 +111,7 @@ describe('integration: rawSql expression in typed builder', { }); const stack = createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter, driver: { diff --git a/test/integration/test/sql-builder/setup.ts b/test/integration/test/sql-builder/setup.ts index e791c72814..2143668d71 100644 --- a/test/integration/test/sql-builder/setup.ts +++ b/test/integration/test/sql-builder/setup.ts @@ -3,6 +3,7 @@ import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; import postgresDriver from '@prisma-next/driver-postgres/runtime'; import pgvector from '@prisma-next/extension-pgvector/runtime'; import { SqlContractSerializer } from '@prisma-next/family-sql/ir'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; import { sql } from '@prisma-next/sql-builder/runtime'; import type { ExecutionContext } from '@prisma-next/sql-relational-core/query-lane-context'; @@ -113,6 +114,7 @@ export function setupIntegrationTest() { }; const stack = createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter, driver: cursorDisabledDriver, diff --git a/test/integration/test/sql-builder/subquery.test.ts b/test/integration/test/sql-builder/subquery.test.ts index 3996b060bf..0594f9cef5 100644 --- a/test/integration/test/sql-builder/subquery.test.ts +++ b/test/integration/test/sql-builder/subquery.test.ts @@ -32,7 +32,7 @@ describe('integration: subqueries', { timeout: timeouts.databaseOperation }, () .select('id', 'name') .where((f, fns) => fns.and( - fns.ne(f.name, 'Bob'), + fns.neq(f.name, 'Bob'), fns.in( f.id, d.posts.select('user_id').where((pf, pfns) => pfns.gt(pf.views, 50)), diff --git a/test/integration/test/sql-builder/where.test.ts b/test/integration/test/sql-builder/where.test.ts index fa40b83cc6..bfe01bbcb6 100644 --- a/test/integration/test/sql-builder/where.test.ts +++ b/test/integration/test/sql-builder/where.test.ts @@ -62,11 +62,11 @@ describe('integration: WHERE', { timeout: timeouts.databaseOperation }, () => { expect(rows[0]!.name).toBe('Alice'); }); - it('ne(col, null) produces IS NOT NULL', async () => { + it('neq(col, null) produces IS NOT NULL', async () => { const rows = await runtime().execute( db() .users.select('id', 'name') - .where((f, fns) => fns.ne(f.invited_by_id, null)) + .where((f, fns) => fns.neq(f.invited_by_id, null)) .orderBy('id') .build(), ); diff --git a/test/integration/test/sql-orm-client/collection-mutation-defaults.test.ts b/test/integration/test/sql-orm-client/collection-mutation-defaults.test.ts index b16ee7d8c2..6db9ea50c2 100644 --- a/test/integration/test/sql-orm-client/collection-mutation-defaults.test.ts +++ b/test/integration/test/sql-orm-client/collection-mutation-defaults.test.ts @@ -1,6 +1,7 @@ import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; import type { ContractModelsMap } from '@prisma-next/contract/types'; import pgvectorRuntime from '@prisma-next/extension-pgvector/runtime'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { Collection } from '@prisma-next/sql-orm-client'; import { createExecutionContext, createSqlExecutionStack } from '@prisma-next/sql-runtime'; import postgresTarget from '@prisma-next/target-postgres/runtime'; @@ -78,6 +79,7 @@ function setupTagCollection(): { const context = createExecutionContext({ contract, stack: createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter, extensionPacks: [pgvectorRuntime], diff --git a/test/integration/test/sql-orm-client/extension-operations.test-d.ts b/test/integration/test/sql-orm-client/extension-operations.test-d.ts index 9573a6cafe..4a05c86cb3 100644 --- a/test/integration/test/sql-orm-client/extension-operations.test-d.ts +++ b/test/integration/test/sql-orm-client/extension-operations.test-d.ts @@ -1,4 +1,4 @@ -import type { ComparisonMethods, ModelAccessor } from '@prisma-next/sql-orm-client'; +import type { ModelAccessor } from '@prisma-next/sql-orm-client'; import { describe, expectTypeOf, test } from 'vitest'; import type { Contract } from './fixtures/generated/contract'; @@ -62,42 +62,75 @@ describe('extension operation argument types', () => { }); }); -describe('extension ops return ComparisonMethods with return-codec traits', () => { +describe('extension ops return registry-derived chained methods filtered by return-codec traits', () => { type CosineDistanceResult = ReturnType; - test('cosineDistance returns numeric comparison methods', () => { - expectTypeOf().toEqualTypeOf< - ComparisonMethods - >(); - }); + // The chained-result surface is derived from the SQL family registry + // (via `ChainedResultMethods` in sql-orm-client/src/types.ts) filtered + // by `OpMatchesField` against the return codec's traits. Concretely: + // cosineDistance returns a pg/float8@1-like numeric codec carrying the + // `equality + order + numeric` trait set, so the chained surface + // exposes every family entry whose `self` matches. - test('cosineDistance result exposes eq', () => { + test('exposes eq (equality trait)', () => { expectTypeOf().toHaveProperty('eq'); }); - test('cosineDistance result exposes gt', () => { + test('exposes neq (equality trait)', () => { + expectTypeOf().toHaveProperty('neq'); + }); + + test('exposes in (equality trait)', () => { + expectTypeOf().toHaveProperty('in'); + }); + + test('exposes notIn (equality trait)', () => { + expectTypeOf().toHaveProperty('notIn'); + }); + + test('exposes gt (order trait)', () => { expectTypeOf().toHaveProperty('gt'); }); - test('cosineDistance result exposes lt', () => { + test('exposes gte (order trait)', () => { + expectTypeOf().toHaveProperty('gte'); + }); + + test('exposes lt (order trait)', () => { expectTypeOf().toHaveProperty('lt'); }); - test('cosineDistance result exposes asc for ordering', () => { + test('exposes lte (order trait)', () => { + expectTypeOf().toHaveProperty('lte'); + }); + + test('exposes isNull (any-codec)', () => { + expectTypeOf().toHaveProperty('isNull'); + }); + + test('exposes isNotNull (any-codec)', () => { + expectTypeOf().toHaveProperty('isNotNull'); + }); + + test('exposes asc for ordering (LegacyOrderingMethods, transient until slice 3b)', () => { expectTypeOf().toHaveProperty('asc'); }); - test('cosineDistance result exposes desc for ordering', () => { + test('exposes desc for ordering (LegacyOrderingMethods, transient until slice 3b)', () => { expectTypeOf().toHaveProperty('desc'); }); - test('cosineDistance result does not expose like (textual-only)', () => { + test('does not expose like (textual trait absent on the numeric return codec)', () => { expectTypeOf().not.toHaveProperty('like'); }); - test('cosineDistance result does not expose ilike (extension op, not comparison method)', () => { + test('does not expose ilike (extension op gated on textual trait, absent on the numeric return codec)', () => { expectTypeOf().not.toHaveProperty('ilike'); }); + + test('does not expose cosineDistance (extension op gated on the pgvector codec, not the numeric return codec)', () => { + expectTypeOf().not.toHaveProperty('cosineDistance'); + }); }); describe('ilike extension operation on text fields', () => { diff --git a/test/integration/test/sql-orm-client/fixtures/generated/contract.d.ts b/test/integration/test/sql-orm-client/fixtures/generated/contract.d.ts index 13362b8c9c..8b7d3adcde 100644 --- a/test/integration/test/sql-orm-client/fixtures/generated/contract.d.ts +++ b/test/integration/test/sql-orm-client/fixtures/generated/contract.d.ts @@ -7,6 +7,7 @@ import type { Vector, } from '@prisma-next/extension-pgvector/codec-types'; import type { QueryOperationTypes as PgVectorQueryOperationTypes } from '@prisma-next/extension-pgvector/operation-types'; +import type { QueryOperationTypes as SqlFamilyQueryOperationTypes } from '@prisma-next/family-sql/operation-types'; import type { Bit, Char, @@ -44,7 +45,8 @@ export type ProfileHash = export type CodecTypes = PgTypes & PgVectorTypes; export type LaneCodecTypes = CodecTypes; -export type QueryOperationTypes = PgAdapterQueryOps & +export type QueryOperationTypes = SqlFamilyQueryOperationTypes & + PgAdapterQueryOps & PgVectorQueryOperationTypes; type DefaultLiteralValue = CodecId extends keyof CodecTypes ? CodecTypes[CodecId]['output'] diff --git a/test/integration/test/sql-orm-client/helpers.ts b/test/integration/test/sql-orm-client/helpers.ts index ed2c7ebaf9..fb941fc285 100644 --- a/test/integration/test/sql-orm-client/helpers.ts +++ b/test/integration/test/sql-orm-client/helpers.ts @@ -2,10 +2,10 @@ import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; import { contractModels, type Contract as FrameworkContract } from '@prisma-next/contract/types'; import pgvectorRuntime from '@prisma-next/extension-pgvector/runtime'; import { SqlContractSerializer } from '@prisma-next/family-sql/ir'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; +import { AsyncIterableResult } from '@prisma-next/framework-components/runtime'; const POSTGRES_DEFAULT_NAMESPACE_ID = 'public' as const; - -import { AsyncIterableResult } from '@prisma-next/framework-components/runtime'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import type { RuntimeQueryable } from '@prisma-next/sql-orm-client'; import type { SelectAst } from '@prisma-next/sql-relational-core/ast'; @@ -90,6 +90,7 @@ function unboundDomainModels(raw: { const testContext: ExecutionContext = createExecutionContext({ contract: baseTestContract, stack: createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter, extensionPacks: [pgvectorRuntime], diff --git a/test/integration/test/sql-orm-client/runtime-helpers.ts b/test/integration/test/sql-orm-client/runtime-helpers.ts index 8c7b16332e..df1bd9afdd 100644 --- a/test/integration/test/sql-orm-client/runtime-helpers.ts +++ b/test/integration/test/sql-orm-client/runtime-helpers.ts @@ -1,6 +1,7 @@ import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; import postgresDriver from '@prisma-next/driver-postgres/runtime'; import pgvectorRuntime from '@prisma-next/extension-pgvector/runtime'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; import type { AsyncIterableResult } from '@prisma-next/framework-components/runtime'; import type { RuntimeQueryable } from '@prisma-next/sql-orm-client'; @@ -66,6 +67,7 @@ export async function createPgIntegrationRuntime( const contract = getTestContract(); const stack = createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter, driver: postgresDriver, diff --git a/test/integration/test/utils.ts b/test/integration/test/utils.ts index d3a12ddbdb..62ac0443a8 100644 --- a/test/integration/test/utils.ts +++ b/test/integration/test/utils.ts @@ -5,6 +5,7 @@ import type { PostgresDriverCreateOptions, } from '@prisma-next/driver-postgres/runtime'; import postgresDriver from '@prisma-next/driver-postgres/runtime'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; import type { SqlStorage } from '@prisma-next/sql-contract/types'; import type { @@ -46,6 +47,7 @@ export async function createTestRuntime( options?: CreateTestRuntimeOptions, ): Promise { const stack = createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter, driver: diff --git a/test/integration/test/value-objects/value-objects.e2e.test.ts b/test/integration/test/value-objects/value-objects.e2e.test.ts index 923ffaad4b..c024c288ae 100644 --- a/test/integration/test/value-objects/value-objects.e2e.test.ts +++ b/test/integration/test/value-objects/value-objects.e2e.test.ts @@ -2,6 +2,7 @@ import postgresAdapter from '@prisma-next/adapter-postgres/runtime'; import postgresDriver from '@prisma-next/driver-postgres/runtime'; import { MongoContractSerializer } from '@prisma-next/family-mongo/ir'; import { SqlContractSerializer } from '@prisma-next/family-sql/ir'; +import sqlRuntimeFamilyDescriptor from '@prisma-next/family-sql/runtime'; import { instantiateExecutionStack } from '@prisma-next/framework-components/execution'; import { mongoOrm } from '@prisma-next/mongo-orm'; import { orm as sqlOrm } from '@prisma-next/sql-orm-client'; @@ -132,6 +133,7 @@ describe('value objects e2e: SQL → real Postgres → typed round-trip', () => ); const stack = createSqlExecutionStack({ + family: sqlRuntimeFamilyDescriptor, target: postgresTarget, adapter: postgresAdapter, driver: postgresDriver,