Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
ad031d1
Add spec and plan for TML-2247 migration planner bugs
wmadden Apr 15, 2026
d48d602
lift mutation default types to framework-components
wmadden Apr 15, 2026
c347802
enrich ComponentMetadata and ContractSourceContext with PSL contribut…
wmadden Apr 15, 2026
141133a
reorder CLI to build ControlStack before calling source provider
wmadden Apr 15, 2026
7692f38
migrate Postgres adapter to ComponentMetadata fields
wmadden Apr 15, 2026
d96b980
update SQL provider to read contributions from ContractSourceContext
wmadden Apr 15, 2026
a3a62f8
fix(mongo): suppress variant collections, merge indexes into base (FL…
wmadden Apr 15, 2026
545b3b6
fix(mongo): compose oneOf-based validator for polymorphic collections…
wmadden Apr 15, 2026
6165915
fix(mongo): derive BSON types from codec lookup, not hardcoded map (F…
wmadden Apr 15, 2026
bb51187
refactor(mongo): read contributions from ContractSourceContext in mon…
wmadden Apr 15, 2026
05a37e9
test(mongo): verify planner produces no createCollection for variant …
wmadden Apr 15, 2026
2d10585
refactor: simplify demo config, remove manual contribution assembly
wmadden Apr 15, 2026
4ccf283
fix(tests): update mock ContractSourceContext for enriched context fi…
wmadden Apr 15, 2026
ab35e8b
docs: update MongoDB Family subsystem doc with validator and polymorp…
wmadden Apr 15, 2026
35c1be8
refactor(sql-family): eliminate SqlControlStaticContributions and SQL…
wmadden Apr 16, 2026
1162fd3
refactor(sql-family): delete assembly functions and obsolete exports
wmadden Apr 16, 2026
edf59a9
test(sql-family): rewrite assembly test to use framework-level functions
wmadden Apr 16, 2026
7bb3779
refactor(mongo): delete scalar-type-descriptors.ts, remove backward-c…
wmadden Apr 16, 2026
4548da5
refactor(sql-contract-psl): remove re-exports from default-function-r…
wmadden Apr 16, 2026
a077d41
refactor(mongo): replace mutable index mutation with immutable update
wmadden Apr 16, 2026
326ff53
refactor(integration): simplify configs and tests to use createContro…
wmadden Apr 16, 2026
782b3f5
fix(integration): complete ContractSourceContext mocks in CLI tests
wmadden Apr 16, 2026
5d9670f
fix: clean up stale references after refactoring
wmadden Apr 16, 2026
2c93445
docs: check off completed acceptance criteria in spec and plan
wmadden Apr 16, 2026
3bd700a
fix: update defineConfig facades and README for framework-level contr…
wmadden Apr 16, 2026
5586e77
Fix composedExtensionPacks to only include actual extension pack IDs
wmadden Apr 16, 2026
2839b86
Add codecLookup to mongo integration test and regenerate fixtures
wmadden Apr 16, 2026
e8fd5e9
Always emit oneOf branches and require discriminatorField in polymorp…
wmadden Apr 16, 2026
f4d51e6
Merge variant indexes into base collection when they share the same c…
wmadden Apr 16, 2026
d077d1e
Use storage-mapped discriminator field name in polymorphic validators
wmadden Apr 16, 2026
3b25e97
Throw when codec lookup fails in buildColumnDescriptorMap
wmadden Apr 16, 2026
0e8a494
Add validatePslScalarTypeCodecIds utility for codec ID validation
wmadden Apr 16, 2026
52df748
Skip unresolvable codecs in buildColumnDescriptorMap instead of throwing
wmadden Apr 16, 2026
95b05ba
Rename snake_case variable to camelCase in assemblePslScalarTypeDescr…
wmadden Apr 16, 2026
6cdc5bc
Rename pslScalarTypeDescriptors to scalarTypeDescriptors
wmadden Apr 17, 2026
b3c8486
Fix typecheck error in polymorphism test
wmadden Apr 17, 2026
2bd5a73
Fix typecheck: guard against empty codec targetTypes in SQL provider
wmadden Apr 17, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 11 additions & 3 deletions docs/architecture docs/subsystems/10. MongoDB Family.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ In the ORM, owned relations auto-project into the parent row — no separate que

### No schema enforcement

SQL databases enforce types at the storage level: if a column is declared as `integer`, you can't store a string in it. MongoDB has no such enforcement by default. A field the contract says is a number might contain a string in the actual database. The contract describes intended structure, not database-enforced constraints.
SQL databases enforce types at the storage level: if a column is declared as `integer`, you can't store a string in it. MongoDB has no such enforcement by default. A field the contract says is a number might contain a string in the actual database. The contract describes intended structure, not database-enforced constraints. However, the migration planner can generate `$jsonSchema` validators for collections, providing opt-in type enforcement at the database level — see [Schema validation](#schema-validation-jsonschema).

`validateMongoContract()` validates the contract itself (not the data in the database) at load time. It performs three layers of validation:

Expand Down Expand Up @@ -164,7 +164,15 @@ The contract represents this with three concepts:
- **Variants**: the specialized models with their type-specific fields
- A **base** reference on each variant, pointing back to the base model

Persistence strategy (whether variants share a collection or each have their own) is emergent from the storage mappings, not declared explicitly. Discriminator narrowing produces polymorphic return types — querying a base model returns a union of variant row types, and filtering by discriminator value narrows the type. See [ADR 173](../adrs/ADR%20173%20-%20Polymorphism%20via%20discriminator%20and%20variants.md).
Persistence strategy (whether variants share a collection or each have their own) is emergent from the storage mappings, not declared explicitly. When variants share a base collection (single-collection polymorphism via `@@base`), the interpreter suppresses separate collection entries for variant models and merges any variant-specific indexes into the base collection. Discriminator narrowing produces polymorphic return types — querying a base model returns a union of variant row types, and filtering by discriminator value narrows the type. See [ADR 173](../adrs/ADR%20173%20-%20Polymorphism%20via%20discriminator%20and%20variants.md).

### Schema validation ($jsonSchema)

MongoDB collections can enforce document structure using `$jsonSchema` validators. The migration planner generates these validators from the contract so that documents inserted or updated in a collection must conform to the expected field types.

BSON types in validators are derived from codec metadata, not hardcoded maps. When the interpreter generates a validator for a collection, it resolves each field's codec ID through the `CodecLookup` and reads the codec's `targetTypes[0]` to get the BSON type name (e.g., `mongo/double@1` → `double`, `mongo/string@1` → `string`). This means any codec registered by an adapter or extension automatically gets correct validator support without modifying the interpreter.

For polymorphic collections (models with `@@discriminator` and `@@base`), the validator uses a `oneOf` pattern: base fields appear in the top-level `properties` and `required`, while variant-specific fields are grouped into `oneOf` branches, each constrained by the discriminator value. This ensures that a document in the collection must match exactly one variant shape.

### Aggregation pipelines

Expand Down Expand Up @@ -255,7 +263,7 @@ The Mongo-specific components are:
- **`MongoRuntime.execute(plan)`** — single entry point `execute<Row>(plan: MongoQueryPlan<Row>): AsyncIterableResult<Row>`. Both read and write operations flow through this path; the ORM’s `compileMongoQuery` returns a `MongoQueryPlan` (for reads, wrapping an `AggregateCommand`).
- **`MongoDriver`** — wraps the `mongodb` Node.js driver, dispatches each wire command type to the correct driver method, and returns results as `AsyncIterable<Document>`.
- **`MongoRuntime`** — validates the plan, calls the driver, and wraps results in `AsyncIterableResult<Row>` (the same streaming result type used by SQL).
- **`MongoCodecRegistry`** — provides base codecs for BSON types (`objectId`, `string`, `int32`, `boolean`, `date`), following the same registry shape as SQL codecs.
- **`MongoCodecRegistry`** — provides base codecs for BSON types (`objectId`, `string`, `int32`, `boolean`, `date`, `double`), following the same registry shape as SQL codecs. Each codec declares its `targetTypes` (e.g., `['double']` for the double codec), which are used to derive BSON type names for `$jsonSchema` validators via `codecLookup.get(codecId).targetTypes[0]`.

### Package layout

Expand Down
29 changes: 2 additions & 27 deletions examples/prisma-next-demo/prisma-next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,45 +3,20 @@ import postgresAdapter from '@prisma-next/adapter-postgres/control';
import { defineConfig } from '@prisma-next/cli/config-types';
import postgresDriver from '@prisma-next/driver-postgres/control';
import pgvector from '@prisma-next/extension-pgvector/control';
import sql, {
assembleAuthoringContributions,
assemblePslInterpretationContributions,
} from '@prisma-next/family-sql/control';
import sql from '@prisma-next/family-sql/control';
import { prismaContract } from '@prisma-next/sql-contract-psl/provider';
import postgres from '@prisma-next/target-postgres/control';

const extensionPacks = [pgvector];
const authoringContributions = assembleAuthoringContributions([
postgres,
postgresAdapter,
...extensionPacks,
]);
const pslContributions = assemblePslInterpretationContributions([
postgres,
postgresAdapter,
...extensionPacks,
]);

export default defineConfig({
family: sql,
target: postgres,
driver: postgresDriver,
adapter: postgresAdapter,
extensionPacks,
extensionPacks: [pgvector],
contract: prismaContract('./prisma/schema.prisma', {
output: 'src/prisma/contract.json',
target: postgres,
authoringContributions,
scalarTypeDescriptors: pslContributions.scalarTypeDescriptors,
controlMutationDefaults: {
defaultFunctionRegistry: pslContributions.defaultFunctionRegistry,
generatorDescriptors: pslContributions.generatorDescriptors,
},
composedExtensionPacks: extensionPacks.map((pack) => pack.id),
}),
// migrations: {
// dir: 'migration-fixtures/long-spine',
// },
db: {
// biome-ignore lint/style/noNonNullAssertion: loaded from .env
connection: process.env['DATABASE_URL']!,
Expand Down
7 changes: 0 additions & 7 deletions examples/retail-store/prisma-next.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,22 +2,15 @@ import mongoAdapter from '@prisma-next/adapter-mongo/control';
import { defineConfig } from '@prisma-next/cli/config-types';
import mongoDriver from '@prisma-next/driver-mongo/control';
import { mongoFamilyDescriptor, mongoTargetDescriptor } from '@prisma-next/family-mongo/control';
import { createMongoScalarTypeDescriptors } from '@prisma-next/mongo-contract-psl';
import { mongoContract } from '@prisma-next/mongo-contract-psl/provider';

const scalarTypeDescriptors = new Map([
...createMongoScalarTypeDescriptors(),
['Float', 'mongo/double@1'],
]);

export default defineConfig({
family: mongoFamilyDescriptor,
target: mongoTargetDescriptor,
adapter: mongoAdapter,
driver: mongoDriver,
contract: mongoContract('./prisma/contract.prisma', {
output: 'src/contract.json',
scalarTypeDescriptors,
}),
db: {
connection: process.env['DB_URL'] ?? 'mongodb://localhost:27017/retail-store',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { Contract } from '@prisma-next/contract/types';
import type { CodecLookup } from '@prisma-next/framework-components/codec';
import type {
AssembledAuthoringContributions,
ControlMutationDefaults,
} from '@prisma-next/framework-components/control';
import type { Result } from '@prisma-next/utils/result';

export interface ContractSourceDiagnosticPosition {
Expand Down Expand Up @@ -33,6 +38,10 @@ export interface ContractSourceDiagnostics {

export interface ContractSourceContext {
readonly composedExtensionPacks: readonly string[];
readonly scalarTypeDescriptors: ReadonlyMap<string, string>;
readonly authoringContributions: AssembledAuthoringContributions;
readonly codecLookup: CodecLookup;
readonly controlMutationDefaults: ControlMutationDefaults;
}

export type ContractSourceProvider = (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,11 @@ import type {
AuthoringTypeNamespace,
} from './framework-authoring';
import type { ComponentMetadata } from './framework-components';
import type {
ControlMutationDefaultEntry,
ControlMutationDefaults,
MutationDefaultGeneratorDescriptor,
} from './mutation-default-types';
import type { TypesImportSpec } from './types-import-spec';

export interface AssembledAuthoringContributions {
Expand All @@ -37,6 +42,8 @@ export interface ControlStack<
readonly extensionIds: ReadonlyArray<string>;
readonly codecLookup: CodecLookup;
readonly authoringContributions: AssembledAuthoringContributions;
readonly scalarTypeDescriptors: ReadonlyMap<string, string>;
readonly controlMutationDefaults: ControlMutationDefaults;
}

export interface CreateControlStackInput<
Expand Down Expand Up @@ -244,6 +251,80 @@ export function assembleAuthoringContributions(
};
}

export function assembleScalarTypeDescriptors(
descriptors: ReadonlyArray<
Pick<ComponentMetadata, 'scalarTypeDescriptors'> & { readonly id?: string }
>,
): ReadonlyMap<string, string> {
const result = new Map<string, string>();
const owners = new Map<string, string>();

for (const descriptor of descriptors) {
const descriptorMap = descriptor.scalarTypeDescriptors;
if (!descriptorMap) continue;
const descriptorId = descriptor.id ?? '<unknown>';
for (const [typeName, codecId] of descriptorMap) {
const existingOwner = owners.get(typeName);
if (existingOwner !== undefined) {
throw new Error(
`Duplicate scalar type descriptor "${typeName}". ` +
`Descriptor "${descriptorId}" conflicts with "${existingOwner}".`,
);
}
result.set(typeName, codecId);
owners.set(typeName, descriptorId);
}
}

return result;
}

export function assembleControlMutationDefaults(
descriptors: ReadonlyArray<
Pick<ComponentMetadata, 'controlMutationDefaults'> & { readonly id?: string }
>,
): ControlMutationDefaults {
const defaultFunctionRegistry = new Map<string, ControlMutationDefaultEntry>();
const functionOwners = new Map<string, string>();
const generatorMap = new Map<string, MutationDefaultGeneratorDescriptor>();
const generatorOwners = new Map<string, string>();

for (const descriptor of descriptors) {
const contributions = descriptor.controlMutationDefaults;
if (!contributions) continue;
const descriptorId = descriptor.id ?? '<unknown>';

for (const generatorDescriptor of contributions.generatorDescriptors) {
const existingOwner = generatorOwners.get(generatorDescriptor.id);
if (existingOwner !== undefined) {
throw new Error(
`Duplicate mutation default generator id "${generatorDescriptor.id}". ` +
`Descriptor "${descriptorId}" conflicts with "${existingOwner}".`,
);
}
generatorMap.set(generatorDescriptor.id, generatorDescriptor);
generatorOwners.set(generatorDescriptor.id, descriptorId);
}

for (const [functionName, handler] of contributions.defaultFunctionRegistry) {
const existingOwner = functionOwners.get(functionName);
if (existingOwner !== undefined) {
throw new Error(
`Duplicate mutation default function "${functionName}". ` +
`Descriptor "${descriptorId}" conflicts with "${existingOwner}".`,
);
}
defaultFunctionRegistry.set(functionName, handler);
functionOwners.set(functionName, descriptorId);
}
}

return {
defaultFunctionRegistry,
generatorDescriptors: Array.from(generatorMap.values()),
};
}

export function extractCodecLookup(
descriptors: ReadonlyArray<Pick<ComponentMetadata & { id?: string }, 'types' | 'id'>>,
): CodecLookup {
Expand All @@ -268,13 +349,31 @@ export function extractCodecLookup(
return { get: (id) => byId.get(id) };
}

export function validateScalarTypeCodecIds(
scalarTypeDescriptors: ReadonlyMap<string, string>,
codecLookup: CodecLookup,
): string[] {
const errors: string[] = [];
for (const [typeName, codecId] of scalarTypeDescriptors) {
if (!codecLookup.get(codecId)) {
errors.push(
`Scalar type "${typeName}" references codec "${codecId}" which is not registered by any component.`,
);
}
}
return errors;
}

export function createControlStack<TFamilyId extends string, TTargetId extends string>(
input: CreateControlStackInput<TFamilyId, TTargetId>,
): ControlStack<TFamilyId, TTargetId> {
const { family, target, adapter, driver, extensionPacks = [] } = input;

const allDescriptors = [family, target, ...(adapter ? [adapter] : []), ...extensionPacks];

const codecLookup = extractCodecLookup(allDescriptors);
const scalarTypeDescriptors = assembleScalarTypeDescriptors(allDescriptors);

return {
family,
target,
Expand All @@ -286,7 +385,9 @@ export function createControlStack<TFamilyId extends string, TTargetId extends s
operationTypeImports: extractOperationTypeImports(allDescriptors),
queryOperationTypeImports: extractQueryOperationTypeImports(allDescriptors),
extensionIds: extractComponentIds(family, target, adapter, extensionPacks),
codecLookup: extractCodecLookup(allDescriptors),
codecLookup,
authoringContributions: assembleAuthoringContributions(allDescriptors),
scalarTypeDescriptors,
controlMutationDefaults: assembleControlMutationDefaults(allDescriptors),
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ export type {
} from '../control-stack';
export {
assembleAuthoringContributions,
assembleControlMutationDefaults,
assembleScalarTypeDescriptors,
assertUniqueCodecOwner,
createControlStack,
extractCodecLookup,
Expand All @@ -74,3 +76,18 @@ export {
extractOperationTypeImports,
extractQueryOperationTypeImports,
} from '../control-stack';
export type {
ControlMutationDefaultEntry,
ControlMutationDefaultRegistry,
ControlMutationDefaults,
DefaultFunctionLoweringContext,
DefaultFunctionLoweringHandler,
DefaultFunctionRegistry,
DefaultFunctionRegistryEntry,
LoweredDefaultResult,
LoweredDefaultValue,
MutationDefaultGeneratorDescriptor,
ParsedDefaultFunctionCall,
SourceDiagnostic,
SourceSpan,
} from '../mutation-default-types';
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { Codec } from './codec-types';
import type { AuthoringContributions } from './framework-authoring';
import type { ControlMutationDefaults } from './mutation-default-types';
import type { TypesImportSpec } from './types-import-spec';

/**
Expand Down Expand Up @@ -65,6 +66,18 @@ export interface ComponentMetadata {
* project them into concrete helper functions for TS-first workflows.
*/
readonly authoring?: AuthoringContributions;

/**
* Scalar type name to codec ID mapping contributed by this component.
* Assembled by `createControlStack` with duplicate detection.
*/
readonly scalarTypeDescriptors?: ReadonlyMap<string, string>;

/**
* Mutation default function handlers and generator descriptors contributed
* by this component. Assembled by `createControlStack` with duplicate detection.
*/
readonly controlMutationDefaults?: ControlMutationDefaults;
}

/**
Expand Down
Loading
Loading