diff --git a/packages/2-sql/9-family/src/core/control-instance.ts b/packages/2-sql/9-family/src/core/control-instance.ts index b835855e87..f7abb4a931 100644 --- a/packages/2-sql/9-family/src/core/control-instance.ts +++ b/packages/2-sql/9-family/src/core/control-instance.ts @@ -10,6 +10,7 @@ import type { TargetDescriptor, } from '@prisma-next/framework-components/components'; import type { + ContractSpace, ControlFamilyInstance, ControlStack, CoreSchemaView, @@ -463,6 +464,33 @@ export function assertNoCrossSpaceFkReverseReferences( } } +const PUBLIC_NAMESPACE_ID = 'public'; + +/** + * Collects the DB table names each extension pack claims in its `public` + * namespace. `contract infer` introspects the `public` schema only and + * introspected tables carry no schema qualifier, so only `public`-namespace + * claims are eligible to suppress an introspected table — a pack claiming + * `auth.users` must never suppress an app's `public.users`. + */ +function collectClaimedPublicTableNames( + extensions: readonly { readonly contractSpace?: ContractSpace> }[], +): ReadonlySet { + const claimed = new Set(); + for (const extension of extensions) { + const namespaces = extension.contractSpace?.contractJson.storage.namespaces; + for (const namespace of Object.values(namespaces ?? {})) { + if (namespace.id !== PUBLIC_NAMESPACE_ID) { + continue; + } + for (const tableName of Object.keys(namespace.entries.table ?? {})) { + claimed.add(tableName); + } + } + } + return claimed; +} + export function createSqlFamilyInstance( stack: ControlStack<'sql', TTargetId>, ): SqlFamilyInstance { @@ -890,7 +918,9 @@ export function createSqlFamilyInstance( }, inferPslContract(schemaIR: SqlSchemaIR): PslDocumentAst { - return sqlSchemaIrToPslAst(schemaIR); + return sqlSchemaIrToPslAst(schemaIR, { + claimedTableNames: collectClaimedPublicTableNames(extensions), + }); }, lowerAst( diff --git a/packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts b/packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts index 96a417e159..2d432e0c40 100644 --- a/packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts +++ b/packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts @@ -71,6 +71,10 @@ type TopLevelNameResult = { readonly map?: string | undefined; }; +export type SqlSchemaIrToPslAstOptions = { + readonly claimedTableNames?: ReadonlySet; +}; + /** * Converts a SQL schema IR into a PSL AST suitable for `printPsl`. * @@ -78,7 +82,10 @@ type TopLevelNameResult = { * relation inference from foreign keys, enum extraction, and raw default parsing. * The output is a fully-formed `PslDocumentAst` with synthetic spans. */ -export function sqlSchemaIrToPslAst(schemaIR: SqlSchemaIR): PslDocumentAst { +export function sqlSchemaIrToPslAst( + schemaIR: SqlSchemaIR, + inferOptions?: SqlSchemaIrToPslAstOptions, +): PslDocumentAst { const enumInfo = extractEnumInfo(schemaIR.annotations); if (enumInfo.typeNames.size > 0) { const names = [...enumInfo.typeNames].join(', '); @@ -96,7 +103,34 @@ export function sqlSchemaIrToPslAst(schemaIR: SqlSchemaIR): PslDocumentAst { parseRawDefault, }; - return buildPslDocumentAst(schemaIR, options); + const filteredSchemaIR = filterClaimedTables(schemaIR, inferOptions?.claimedTableNames); + + return buildPslDocumentAst(filteredSchemaIR, options); +} + +function filterClaimedTables( + schemaIR: SqlSchemaIR, + claimedTableNames: ReadonlySet | undefined, +): SqlSchemaIR { + if (!claimedTableNames || claimedTableNames.size === 0) { + return schemaIR; + } + + const tables = Object.fromEntries( + Object.entries(schemaIR.tables) + .filter(([tableName]) => !claimedTableNames.has(tableName)) + .map(([tableName, table]) => { + const survivingForeignKeys = table.foreignKeys.filter( + (fk) => !claimedTableNames.has(fk.referencedTable), + ); + if (survivingForeignKeys.length === table.foreignKeys.length) { + return [tableName, table]; + } + return [tableName, { ...table, foreignKeys: survivingForeignKeys }]; + }), + ); + + return { ...schemaIR, tables }; } function buildPslDocumentAst(schemaIR: SqlSchemaIR, options: PslPrinterOptions): PslDocumentAst { diff --git a/packages/2-sql/9-family/test/psl-contract-infer/infer-psl-contract.extension-aware.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/infer-psl-contract.extension-aware.test.ts new file mode 100644 index 0000000000..91ff40c260 --- /dev/null +++ b/packages/2-sql/9-family/test/psl-contract-infer/infer-psl-contract.extension-aware.test.ts @@ -0,0 +1,240 @@ +import { computeStorageHash } from '@prisma-next/contract/hashing'; +import { type Contract, coreHash, profileHash } from '@prisma-next/contract/types'; +import type { + ContractSpace, + ControlFamilyDescriptor, + ControlStack, +} from '@prisma-next/framework-components/control'; +import { createControlStack } from '@prisma-next/framework-components/control'; +import { flatPslModels } from '@prisma-next/framework-components/psl-ast'; +import { sqlContractCanonicalizationHooks } from '@prisma-next/sql-contract/canonicalization-hooks'; +import { SqlStorage } from '@prisma-next/sql-contract/types'; +import type { SqlSchemaIR } from '@prisma-next/sql-schema-ir/types'; +import { applicationDomainOf } from '@prisma-next/test-utils'; +import { describe, expect, it } from 'vitest'; +import { createTestSqlNamespace } from '../../../1-core/contract/test/test-support'; +import { createSqlFamilyInstance } from '../../src/core/control-instance'; +import type { SqlControlExtensionDescriptor } from '../../src/core/migrations/types'; + +const TARGET = 'postgres' as const; +const TARGET_FAMILY = 'sql' as const; + +function buildExtension(opts: { + readonly id: string; + readonly namespaceId: string; + readonly tables: Record; + readonly namespaceKey?: string; +}): SqlControlExtensionDescriptor<'postgres'> { + const namespaceKey = opts.namespaceKey ?? opts.namespaceId; + const allTables = Object.fromEntries( + Object.entries(opts.tables).map(([name, columns]) => [ + name, + { columns, uniques: [], indexes: [], foreignKeys: [] }, + ]), + ); + + const hash = computeStorageHash({ + target: TARGET, + targetFamily: TARGET_FAMILY, + storage: { + namespaces: { + [namespaceKey]: { + id: opts.namespaceId, + entries: { table: allTables }, + }, + }, + }, + ...sqlContractCanonicalizationHooks, + }); + + const contract: Contract = { + target: TARGET, + targetFamily: TARGET_FAMILY, + roots: {}, + domain: applicationDomainOf({ models: {} }), + capabilities: {}, + extensionPacks: {}, + meta: {}, + profileHash: profileHash('fixture-profile-v1'), + storage: new SqlStorage({ + storageHash: coreHash(hash), + namespaces: { + [namespaceKey]: createTestSqlNamespace({ + id: opts.namespaceId, + entries: { table: allTables as never }, + }), + }, + }), + }; + + return { + kind: 'extension' as const, + id: opts.id, + familyId: 'sql' as const, + targetId: 'postgres' as const, + version: '0.0.1', + contractSpace: { + contractJson: contract, + migrations: [], + headRef: { hash: contract.storage.storageHash as string, invariants: [] }, + } satisfies ContractSpace>, + create: () => ({ familyId: 'sql' as const, targetId: 'postgres' as const }), + }; +} + +function makeStack( + extensions: readonly SqlControlExtensionDescriptor<'postgres'>[], +): ControlStack<'sql', 'postgres'> { + return createControlStack({ + family: { + kind: 'family', + id: 'sql', + familyId: 'sql', + version: '0.0.1', + create: (() => ({})) as unknown as ControlFamilyDescriptor<'sql'>['create'], + emission: { + id: 'sql', + generateStorageType: () => '{ readonly storageHash: StorageHash }', + generateModelStorageType: () => 'Record', + getFamilyImports: () => [], + getFamilyTypeAliases: () => '', + getTypeMapsExpression: () => 'unknown', + getContractWrapper: (base: string) => `export type Contract = ${base};`, + }, + }, + target: { + kind: 'target', + id: 'postgres', + version: '0.0.1', + familyId: 'sql', + targetId: 'postgres', + contractSerializer: { + deserializeContract: (json) => json as never, + serializeContract: (contract) => contract as never, + }, + create: () => ({ familyId: 'sql', targetId: 'postgres' }), + }, + adapter: { + kind: 'adapter', + id: 'postgres', + version: '0.0.1', + familyId: 'sql', + targetId: 'postgres', + create: () => ({ familyId: 'sql', targetId: 'postgres' }), + }, + extensionPacks: extensions, + }); +} + +function tableIr(name: string): SqlSchemaIR['tables'][string] { + return { + name, + columns: { id: { name: 'id', nativeType: 'int4', nullable: false } }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }; +} + +function tableIrWithFk( + name: string, + fk: { readonly column: string; readonly referencedTable: string }, +): SqlSchemaIR['tables'][string] { + return { + name, + columns: { + id: { name: 'id', nativeType: 'int4', nullable: false }, + [fk.column]: { name: fk.column, nativeType: 'int4', nullable: false }, + }, + primaryKey: { columns: ['id'] }, + foreignKeys: [ + { columns: [fk.column], referencedTable: fk.referencedTable, referencedColumns: ['id'] }, + ], + uniques: [], + indexes: [], + }; +} + +describe('inferPslContract extension awareness', () => { + it('omits a table an extension pack claims in its public namespace', () => { + const ext = buildExtension({ + id: 'ext-owned', + namespaceId: 'public', + tables: { t_owned: { id: { codecId: 'pg/int4@1', nativeType: 'integer', nullable: false } } }, + }); + const instance = createSqlFamilyInstance(makeStack([ext])); + + const schemaIR: SqlSchemaIR = { + tables: { app_table: tableIr('app_table'), t_owned: tableIr('t_owned') }, + }; + + const ast = instance.inferPslContract(schemaIR); + const modelNames = flatPslModels(ast).map((m) => m.name); + expect(modelNames).toContain('AppTable'); + expect(modelNames).not.toContain('TOwned'); + }); + + it('keeps an introspected public table when the pack claims the same name in a non-public namespace', () => { + const ext = buildExtension({ + id: 'ext-auth', + namespaceId: 'auth', + tables: { users: { id: { codecId: 'pg/int4@1', nativeType: 'integer', nullable: false } } }, + }); + const instance = createSqlFamilyInstance(makeStack([ext])); + + const schemaIR: SqlSchemaIR = { + tables: { users: tableIr('users'), app_table: tableIr('app_table') }, + }; + + const ast = instance.inferPslContract(schemaIR); + const modelNames = flatPslModels(ast).map((m) => m.name); + expect(modelNames).toContain('Users'); + expect(modelNames).toContain('AppTable'); + }); + + it('drops a surviving table field pointing at a claimed (omitted) table', () => { + const ext = buildExtension({ + id: 'ext-owned', + namespaceId: 'public', + tables: { t_owned: { id: { codecId: 'pg/int4@1', nativeType: 'integer', nullable: false } } }, + }); + const instance = createSqlFamilyInstance(makeStack([ext])); + + const schemaIR: SqlSchemaIR = { + tables: { + posts: tableIrWithFk('posts', { column: 'author_id', referencedTable: 't_owned' }), + t_owned: tableIr('t_owned'), + }, + }; + + const ast = instance.inferPslContract(schemaIR); + const models = flatPslModels(ast); + const modelNames = models.map((m) => m.name); + expect(modelNames).toContain('Posts'); + expect(modelNames).not.toContain('TOwned'); + + const fieldTypeNames = models.flatMap((m) => m.fields.map((f) => f.typeName)); + expect(fieldTypeNames).not.toContain('TOwned'); + expect(fieldTypeNames).not.toContain('t_owned'); + }); + + it('matches the public namespace by its `.id`, not its record key', () => { + const ext = buildExtension({ + id: 'ext-owned', + namespaceId: 'public', + namespaceKey: 'not-public', + tables: { t_owned: { id: { codecId: 'pg/int4@1', nativeType: 'integer', nullable: false } } }, + }); + const instance = createSqlFamilyInstance(makeStack([ext])); + + const schemaIR: SqlSchemaIR = { + tables: { app_table: tableIr('app_table'), t_owned: tableIr('t_owned') }, + }; + + const ast = instance.inferPslContract(schemaIR); + const modelNames = flatPslModels(ast).map((m) => m.name); + expect(modelNames).toContain('AppTable'); + expect(modelNames).not.toContain('TOwned'); + }); +}); diff --git a/packages/2-sql/9-family/test/psl-contract-infer/sql-schema-ir-to-psl-ast.test.ts b/packages/2-sql/9-family/test/psl-contract-infer/sql-schema-ir-to-psl-ast.test.ts index 7ee01ded3e..4c013632d0 100644 --- a/packages/2-sql/9-family/test/psl-contract-infer/sql-schema-ir-to-psl-ast.test.ts +++ b/packages/2-sql/9-family/test/psl-contract-infer/sql-schema-ir-to-psl-ast.test.ts @@ -202,6 +202,62 @@ describe('sqlSchemaIrToPslAst', () => { expect(user?.comment).toBeUndefined(); }); + it('omits tables whose name is in claimedTableNames', () => { + const schemaIR = ir({ + tables: { + app_table: { + name: 'app_table', + columns: { id: { name: 'id', nativeType: 'int4', nullable: false } }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + t_owned: { + name: 't_owned', + columns: { id: { name: 'id', nativeType: 'int4', nullable: false } }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }, + }); + + const ast = sqlSchemaIrToPslAst(schemaIR, { claimedTableNames: new Set(['t_owned']) }); + const modelNames = flatPslModels(ast).map((m) => m.name); + expect(modelNames).toContain('AppTable'); + expect(modelNames).not.toContain('TOwned'); + }); + + it('keeps all tables when no options are passed', () => { + const schemaIR = ir({ + tables: { + app_table: { + name: 'app_table', + columns: { id: { name: 'id', nativeType: 'int4', nullable: false } }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + t_owned: { + name: 't_owned', + columns: { id: { name: 'id', nativeType: 'int4', nullable: false } }, + primaryKey: { columns: ['id'] }, + foreignKeys: [], + uniques: [], + indexes: [], + }, + }, + }); + + const ast = sqlSchemaIrToPslAst(schemaIR); + const modelNames = flatPslModels(ast).map((m) => m.name); + expect(modelNames).toEqual(expect.arrayContaining(['AppTable', 'TOwned'])); + expect(modelNames).toHaveLength(2); + }); + it('renders a representative two-table schema with FK relation deterministically', () => { const schemaIR = ir({ tables: { diff --git a/projects/extension-supabase/slices/g-extension-aware-infer/spec.md b/projects/extension-supabase/slices/g-extension-aware-infer/spec.md new file mode 100644 index 0000000000..70f5c1793f --- /dev/null +++ b/projects/extension-supabase/slices/g-extension-aware-infer/spec.md @@ -0,0 +1,81 @@ +# Slice G — Extension-aware `contract infer` + +**Linear:** TML-2962 +**Gate:** none. Independent of native enums, RLS, and the complete-contract work (Slice F). + +## Requirement + +When a stack extension pack claims a DB element, `contract infer` must omit that +element from the `contract.prisma` it writes. A brownfield project sitting on top +of an extension (e.g. Supabase) should get an inferred contract of *its own* +tables, not the extension's. + +## Current behaviour (as-built) + +- `contract infer` runs through `inspectLiveSchema`, which builds a control client + from the config (packs included) and calls `client.inferPslContract(schema)`. +- Infer passes **no contract** to `introspect`, so the Postgres adapter reads the + **`public`** schema only. The resulting `SqlSchemaIR.tables` is a flat map keyed + by DB table name, with no schema qualifier. +- `client.inferPslContract` → SQL family instance `inferPslContract(schemaIR)` → + `sqlSchemaIrToPslAst(schemaIR)`. The family instance holds the stack's + `extensions`, but `inferPslContract` ignores them today. +- Each pack's `contractSpace.contractJson.storage.namespaces` is keyed by DDL + schema id (`auth` / `public` / `storage`); each namespace's `entries.table` is + keyed by DB table name. (The shipped Supabase pack owns tables in `auth` and + `storage`; its `public` namespace is empty.) + +So today infer already omits `auth.*` / `storage.*` incidentally — it never reads +those schemas. The real gap: a pack that claims a **`public`** table has that +table leak into the app's inferred contract. + +## Design + +Make `inferPslContract` extension-aware: + +1. The family instance derives the set of **claimed table names in the schema + infer reads (`public`)**: for each stack pack with a `contractSpace`, take the + `public` namespace (`namespaces` entry whose `id === 'public'`) and collect its + `entries.table` keys. +2. `sqlSchemaIrToPslAst` gains an options bag carrying `claimedTableNames` + (`ReadonlySet`). It drops any `schemaIR.tables` entry whose key/name is + in that set before building models. + +**Namespace-correctness (the important part):** only `public`-namespace claims +count. A pack claiming `auth.users` must NOT omit an app's `public.users`. Because +introspected tables are all `public` and pack claims are matched only against the +pack's `public` namespace, a same-named table in a non-public pack namespace never +clobbers an app table. + +### Seams + +- `packages/2-sql/9-family/src/core/psl-contract-infer/sql-schema-ir-to-psl-ast.ts` + — `sqlSchemaIrToPslAst(schemaIR, options?)`; filter `tables` up front. +- `packages/2-sql/9-family/src/core/control-instance.ts` (`inferPslContract`, ~L892) + — compute `claimedTableNames` from the closed-over `extensions`, pass through. + +No introspection-scope change, no CLI change, no contract-shape change. + +## Definition of done + +Tests first, then implementation. + +- [ ] Unit (`sql-schema-ir-to-psl-ast.test.ts`): given an IR with `app_table` + + `t_owned` and `claimedTableNames: {'t_owned'}`, the AST contains `AppTable` + and omits `TOwned`. With no options, output is unchanged (byte-identical to + today — existing tests stay green). +- [ ] Family-instance test: build a `ControlStack` with an extension pack whose + contract declares a table in its **`public`** namespace; `inferPslContract` + on an IR containing that table + an app table omits the pack's table and + keeps the app's. +- [ ] Namespace-correctness test: a pack that claims the table in a **non-public** + namespace (`auth`) does NOT omit the same-named `public` table from infer. +- [ ] `pnpm fixtures:check` clean; existing infer output for pack-free stacks + unchanged. + +## Out of scope + +- Broadening infer to introspect `auth`/`storage` (that's the complete-contract / + Slice F direction, and pulls in native-enum handling). +- Omitting non-table elements (enums, roles) — tables only for this slice. +- Any change to `db verify` / control-policy behaviour.