diff --git a/examples/mongo-demo/migrations/app/20260626T1605_add_user_role_enum/end-contract.d.ts b/examples/mongo-demo/migrations/app/20260626T1605_add_user_role_enum/end-contract.d.ts index c74d0490ae..e819599786 100644 --- a/examples/mongo-demo/migrations/app/20260626T1605_add_user_role_enum/end-contract.d.ts +++ b/examples/mongo-demo/migrations/app/20260626T1605_add_user_role_enum/end-contract.d.ts @@ -17,7 +17,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:250af57beb0580c2c9562789d5d05ae39bcfabd08b2eca8367f59a70fa724b7d'>; + StorageHashBase<'sha256:79b46070809bf632b3742219ce1dd8924daf6350b2f478c4732962cf96288b6e'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'sha256:cca47cfb902adf4e15c2f277dd98af4aff64a3a2c010b49ace1c897de1cc4510'>; diff --git a/examples/mongo-demo/migrations/app/20260626T1605_add_user_role_enum/end-contract.json b/examples/mongo-demo/migrations/app/20260626T1605_add_user_role_enum/end-contract.json index d16ed83d2f..2c33046711 100644 --- a/examples/mongo-demo/migrations/app/20260626T1605_add_user_role_enum/end-contract.json +++ b/examples/mongo-demo/migrations/app/20260626T1605_add_user_role_enum/end-contract.json @@ -428,13 +428,19 @@ "validationLevel": "strict" } } + }, + "valueSet": { + "UserRole": { + "kind": "valueSet", + "values": ["admin", "author", "reader"] + } } }, "id": "__unbound__", "kind": "mongo-database" } }, - "storageHash": "sha256:250af57beb0580c2c9562789d5d05ae39bcfabd08b2eca8367f59a70fa724b7d" + "storageHash": "sha256:79b46070809bf632b3742219ce1dd8924daf6350b2f478c4732962cf96288b6e" }, "capabilities": {}, "extensionPacks": {}, diff --git a/examples/mongo-demo/migrations/app/20260626T1605_add_user_role_enum/migration.json b/examples/mongo-demo/migrations/app/20260626T1605_add_user_role_enum/migration.json index d9e5cff6a4..f08a9c92e4 100644 --- a/examples/mongo-demo/migrations/app/20260626T1605_add_user_role_enum/migration.json +++ b/examples/mongo-demo/migrations/app/20260626T1605_add_user_role_enum/migration.json @@ -1,7 +1,7 @@ { "from": "sha256:2827cbad7293fe13a4fb2aab60a55d3cddd856a86d1f6ccea6e11519faacff92", - "to": "sha256:250af57beb0580c2c9562789d5d05ae39bcfabd08b2eca8367f59a70fa724b7d", + "to": "sha256:79b46070809bf632b3742219ce1dd8924daf6350b2f478c4732962cf96288b6e", "providedInvariants": [], "createdAt": "2026-06-26T16:05:45.955Z", - "migrationHash": "sha256:96b82b55c805e5da24a48021cfbc265891888d417531552cba46e533ba0fd5d1" + "migrationHash": "sha256:2d0c345862c41e6b22a91cea43cfd85e58606746339779ada93e9789523f9f5f" } diff --git a/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/end-contract.d.ts b/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/end-contract.d.ts index 3ec52b6a4b..c0dec5ceba 100644 --- a/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/end-contract.d.ts +++ b/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/end-contract.d.ts @@ -17,7 +17,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:ecc554e5f2f05ec120f8fef5ddf536286471edd3de11b8a906ba70e71f5e5df3'>; + StorageHashBase<'sha256:2b8eb72bd167d1a8c60e1527bdb14fb6c1901407a121d27d117b314dd934cd7d'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'sha256:cca47cfb902adf4e15c2f277dd98af4aff64a3a2c010b49ace1c897de1cc4510'>; diff --git a/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/end-contract.json b/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/end-contract.json index 6749fd3516..674330e70b 100644 --- a/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/end-contract.json +++ b/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/end-contract.json @@ -450,13 +450,19 @@ "validationLevel": "strict" } } + }, + "valueSet": { + "UserRole": { + "kind": "valueSet", + "values": ["admin", "author", "reader"] + } } }, "id": "__unbound__", "kind": "mongo-database" } }, - "storageHash": "sha256:ecc554e5f2f05ec120f8fef5ddf536286471edd3de11b8a906ba70e71f5e5df3" + "storageHash": "sha256:2b8eb72bd167d1a8c60e1527bdb14fb6c1901407a121d27d117b314dd934cd7d" }, "capabilities": {}, "extensionPacks": {}, diff --git a/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/migration.json b/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/migration.json index 597d4ba22e..2eacdb615b 100644 --- a/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/migration.json +++ b/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/migration.json @@ -1,7 +1,7 @@ { - "from": "sha256:250af57beb0580c2c9562789d5d05ae39bcfabd08b2eca8367f59a70fa724b7d", - "to": "sha256:ecc554e5f2f05ec120f8fef5ddf536286471edd3de11b8a906ba70e71f5e5df3", + "from": "sha256:79b46070809bf632b3742219ce1dd8924daf6350b2f478c4732962cf96288b6e", + "to": "sha256:2b8eb72bd167d1a8c60e1527bdb14fb6c1901407a121d27d117b314dd934cd7d", "providedInvariants": [], "createdAt": "2026-06-26T19:16:47.657Z", - "migrationHash": "sha256:b9b8bf32b82818be990ece6376e156749bc979db55aa8e2fe438bde423c3e7ae" + "migrationHash": "sha256:f333f59f2fa28ab0e576f4afb0ed964c3d4e770ae0f4b5c5c4555d32fcefed96" } diff --git a/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/start-contract.d.ts b/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/start-contract.d.ts index c74d0490ae..e819599786 100644 --- a/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/start-contract.d.ts +++ b/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/start-contract.d.ts @@ -17,7 +17,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:250af57beb0580c2c9562789d5d05ae39bcfabd08b2eca8367f59a70fa724b7d'>; + StorageHashBase<'sha256:79b46070809bf632b3742219ce1dd8924daf6350b2f478c4732962cf96288b6e'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'sha256:cca47cfb902adf4e15c2f277dd98af4aff64a3a2c010b49ace1c897de1cc4510'>; diff --git a/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/start-contract.json b/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/start-contract.json index d16ed83d2f..2c33046711 100644 --- a/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/start-contract.json +++ b/examples/mongo-demo/migrations/app/20260626T1916_add_posts_indexes/start-contract.json @@ -428,13 +428,19 @@ "validationLevel": "strict" } } + }, + "valueSet": { + "UserRole": { + "kind": "valueSet", + "values": ["admin", "author", "reader"] + } } }, "id": "__unbound__", "kind": "mongo-database" } }, - "storageHash": "sha256:250af57beb0580c2c9562789d5d05ae39bcfabd08b2eca8367f59a70fa724b7d" + "storageHash": "sha256:79b46070809bf632b3742219ce1dd8924daf6350b2f478c4732962cf96288b6e" }, "capabilities": {}, "extensionPacks": {}, diff --git a/examples/mongo-demo/src/contract.d.ts b/examples/mongo-demo/src/contract.d.ts index 3ec52b6a4b..c0dec5ceba 100644 --- a/examples/mongo-demo/src/contract.d.ts +++ b/examples/mongo-demo/src/contract.d.ts @@ -17,7 +17,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:ecc554e5f2f05ec120f8fef5ddf536286471edd3de11b8a906ba70e71f5e5df3'>; + StorageHashBase<'sha256:2b8eb72bd167d1a8c60e1527bdb14fb6c1901407a121d27d117b314dd934cd7d'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'sha256:cca47cfb902adf4e15c2f277dd98af4aff64a3a2c010b49ace1c897de1cc4510'>; diff --git a/examples/mongo-demo/src/contract.json b/examples/mongo-demo/src/contract.json index 21688a43c9..a16789b2b9 100644 --- a/examples/mongo-demo/src/contract.json +++ b/examples/mongo-demo/src/contract.json @@ -495,13 +495,23 @@ "validationLevel": "strict" } } + }, + "valueSet": { + "UserRole": { + "kind": "valueSet", + "values": [ + "admin", + "author", + "reader" + ] + } } }, "id": "__unbound__", "kind": "mongo-database" } }, - "storageHash": "sha256:ecc554e5f2f05ec120f8fef5ddf536286471edd3de11b8a906ba70e71f5e5df3" + "storageHash": "sha256:2b8eb72bd167d1a8c60e1527bdb14fb6c1901407a121d27d117b314dd934cd7d" }, "capabilities": {}, "extensionPacks": {}, diff --git a/examples/mongo-demo/test/enum-value-set.types.test-d.ts b/examples/mongo-demo/test/enum-value-set.types.test-d.ts new file mode 100644 index 0000000000..b114860cc7 --- /dev/null +++ b/examples/mongo-demo/test/enum-value-set.types.test-d.ts @@ -0,0 +1,44 @@ +/** + * Acceptance: a Mongo enum document field types as the value union on BOTH the emitted + * `contract.d.ts` path (`FieldOutputTypes` / ORM `DefaultModelRow`) AND the no-emit `typeof + * contract` path (the domain enum's value union via `NamespacedEnums`), and the two agree — the + * same shape SQL produces (`FieldOutputTypes[][][]` narrows to the value union). + * + * The emit side consumes the real emitted `contract.d.ts` (emit-then-consume), not `typeof + * contract`, so a divergence between authoring and emission is caught. + */ + +import type { EnumValues, NamespacedEnums } from '@prisma-next/contract/enum-accessor'; +import type { DefaultModelRow } from '@prisma-next/mongo-orm'; +import { expectTypeOf, test } from 'vitest'; +import type { Contract, FieldOutputTypes } from '../src/contract'; + +const ROLE_UNION = ['admin', 'author', 'reader'] as const; +type RoleUnion = (typeof ROLE_UNION)[number]; + +// Emit side: the emitted FieldOutputTypes narrows the enum field to the value union. +type EmittedRole = FieldOutputTypes['__unbound__']['User']['role']; + +// Emit side (consumer): the ORM read row derives the same union from the emitted contract. +type OrmRole = DefaultModelRow['role']; + +// No-emit side: the value union carried by the domain enum (`typeof contract`, via db.enums). +type NoEmitRoleValues = EnumValues['__unbound__']['UserRole']>; + +test('emit: FieldOutputTypes types the enum field as the value union, not string', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toEqualTypeOf(); +}); + +test('emit: the ORM read row narrows the enum field to the value union', () => { + expectTypeOf().toEqualTypeOf(); + expectTypeOf().not.toEqualTypeOf(); +}); + +test('no-emit: the domain enum value union equals the same value union', () => { + expectTypeOf().toEqualTypeOf(); +}); + +test('emit and no-emit agree: the emitted field type equals the no-emit value union', () => { + expectTypeOf().toEqualTypeOf(); +}); diff --git a/examples/retail-store/migrations/app/20260628T0931_add_product_status_order_type_enums/end-contract.d.ts b/examples/retail-store/migrations/app/20260628T0931_add_product_status_order_type_enums/end-contract.d.ts index 264eae2f37..1ef8e31e6e 100644 --- a/examples/retail-store/migrations/app/20260628T0931_add_product_status_order_type_enums/end-contract.d.ts +++ b/examples/retail-store/migrations/app/20260628T0931_add_product_status_order_type_enums/end-contract.d.ts @@ -17,7 +17,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:24e1562cabc8241f7fd50b830ce29ea955b5cd668488fbfb5d6744b48d174d14'>; + StorageHashBase<'sha256:950513819883fab7f2e961cfbea6ba069bec18ff2df023f9d0c9d6836c51feec'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'sha256:cca47cfb902adf4e15c2f277dd98af4aff64a3a2c010b49ace1c897de1cc4510'>; diff --git a/examples/retail-store/migrations/app/20260628T0931_add_product_status_order_type_enums/end-contract.json b/examples/retail-store/migrations/app/20260628T0931_add_product_status_order_type_enums/end-contract.json index 6ca6b52b20..8f8fb5de95 100644 --- a/examples/retail-store/migrations/app/20260628T0931_add_product_status_order_type_enums/end-contract.json +++ b/examples/retail-store/migrations/app/20260628T0931_add_product_status_order_type_enums/end-contract.json @@ -1472,13 +1472,23 @@ "validationLevel": "strict" } } + }, + "valueSet": { + "OrderType": { + "kind": "valueSet", + "values": ["delivery", "pickup"] + }, + "ProductStatus": { + "kind": "valueSet", + "values": ["active", "discontinued", "out-of-stock"] + } } }, "id": "__unbound__", "kind": "mongo-database" } }, - "storageHash": "sha256:24e1562cabc8241f7fd50b830ce29ea955b5cd668488fbfb5d6744b48d174d14" + "storageHash": "sha256:950513819883fab7f2e961cfbea6ba069bec18ff2df023f9d0c9d6836c51feec" }, "capabilities": {}, "extensionPacks": {}, diff --git a/examples/retail-store/migrations/app/20260628T0931_add_product_status_order_type_enums/migration.json b/examples/retail-store/migrations/app/20260628T0931_add_product_status_order_type_enums/migration.json index cb29991e23..7eaca3a096 100644 --- a/examples/retail-store/migrations/app/20260628T0931_add_product_status_order_type_enums/migration.json +++ b/examples/retail-store/migrations/app/20260628T0931_add_product_status_order_type_enums/migration.json @@ -1,7 +1,7 @@ { "from": "sha256:71f1cc5c3f4de1ea7c9c8426fde682cd78c7c005f6688f58c2d9d6ddd8b2284c", - "to": "sha256:24e1562cabc8241f7fd50b830ce29ea955b5cd668488fbfb5d6744b48d174d14", + "to": "sha256:950513819883fab7f2e961cfbea6ba069bec18ff2df023f9d0c9d6836c51feec", "providedInvariants": [], "createdAt": "2026-06-28T09:31:36.433Z", - "migrationHash": "sha256:6bbc7d557931ab7f989c59c09121d1c89877b6a98539820c5f046d4594a3fcc1" + "migrationHash": "sha256:379ba7c197317f2ef83d9c3a9a196b68e08afdc1e0a9f230b1b9260a1ba13b69" } diff --git a/examples/retail-store/src/contract.d.ts b/examples/retail-store/src/contract.d.ts index 264eae2f37..1ef8e31e6e 100644 --- a/examples/retail-store/src/contract.d.ts +++ b/examples/retail-store/src/contract.d.ts @@ -17,7 +17,7 @@ import type { } from '@prisma-next/contract/types'; export type StorageHash = - StorageHashBase<'sha256:24e1562cabc8241f7fd50b830ce29ea955b5cd668488fbfb5d6744b48d174d14'>; + StorageHashBase<'sha256:950513819883fab7f2e961cfbea6ba069bec18ff2df023f9d0c9d6836c51feec'>; export type ExecutionHash = ExecutionHashBase; export type ProfileHash = ProfileHashBase<'sha256:cca47cfb902adf4e15c2f277dd98af4aff64a3a2c010b49ace1c897de1cc4510'>; diff --git a/examples/retail-store/src/contract.json b/examples/retail-store/src/contract.json index b04b96eead..8afc8f4857 100644 --- a/examples/retail-store/src/contract.json +++ b/examples/retail-store/src/contract.json @@ -1602,13 +1602,30 @@ "validationLevel": "strict" } } + }, + "valueSet": { + "OrderType": { + "kind": "valueSet", + "values": [ + "delivery", + "pickup" + ] + }, + "ProductStatus": { + "kind": "valueSet", + "values": [ + "active", + "discontinued", + "out-of-stock" + ] + } } }, "id": "__unbound__", "kind": "mongo-database" } }, - "storageHash": "sha256:24e1562cabc8241f7fd50b830ce29ea955b5cd668488fbfb5d6744b48d174d14" + "storageHash": "sha256:950513819883fab7f2e961cfbea6ba069bec18ff2df023f9d0c9d6836c51feec" }, "capabilities": {}, "extensionPacks": {}, diff --git a/packages/1-framework/1-core/errors/src/control.ts b/packages/1-framework/1-core/errors/src/control.ts index 7dcc773be2..22eee14c5c 100644 --- a/packages/1-framework/1-core/errors/src/control.ts +++ b/packages/1-framework/1-core/errors/src/control.ts @@ -471,6 +471,27 @@ export function errorConfigValidation( // Generic Error // ============================================================================ +/** + * An enum declares a codecId that no component in the contract's pack stack provides, + * so its member values cannot be encoded. Thrown by both authoring paths (TS `defineContract` + * and PSL interpretation) when the codec lookup built from the contract's packs has no + * descriptor for the codecId. + */ +export function errorEnumCodecNotInPackStack(options: { + readonly codecId: string; +}): CliStructuredError { + return new CliStructuredError( + '4016', + `Enum codec "${options.codecId}" is not part of the contract's pack stack`, + { + domain: 'CON', + why: `An enum uses codec "${options.codecId}", but no family, target, or extension pack in the contract provides it.`, + fix: "Use a codec provided by the contract's target/extension packs, or add the pack that supplies this codec.", + meta: { codecId: options.codecId }, + }, + ); +} + /** * Generic unexpected error. */ diff --git a/packages/1-framework/1-core/errors/src/exports/control.ts b/packages/1-framework/1-core/errors/src/exports/control.ts index ce688f9db0..54ca77dd26 100644 --- a/packages/1-framework/1-core/errors/src/exports/control.ts +++ b/packages/1-framework/1-core/errors/src/exports/control.ts @@ -8,6 +8,7 @@ export { errorContractValidationFailed, errorDatabaseConnectionRequired, errorDriverRequired, + errorEnumCodecNotInPackStack, errorFamilyReadMarkerSqlRequired, errorFileNotFound, errorInvalidOutputFormat, diff --git a/packages/1-framework/1-core/errors/test/control.test.ts b/packages/1-framework/1-core/errors/test/control.test.ts index e6480c8074..cdb0e8accd 100644 --- a/packages/1-framework/1-core/errors/test/control.test.ts +++ b/packages/1-framework/1-core/errors/test/control.test.ts @@ -8,6 +8,7 @@ import { errorContractValidationFailed, errorDatabaseConnectionRequired, errorDriverRequired, + errorEnumCodecNotInPackStack, errorFamilyReadMarkerSqlRequired, errorFileNotFound, errorInvalidOutputFormat, @@ -440,6 +441,15 @@ describe('Config Errors', () => { expect(envelope.code).toBe('PN-CLI-4015'); expect(error.message).toMatch(/--format pretty.*--json/i); }); + + it('errorEnumCodecNotInPackStack produces PN-CON-4016', () => { + const error = errorEnumCodecNotInPackStack({ codecId: 'mongo/string@1' }); + const envelope = error.toEnvelope(); + expect(error.code).toBe('4016'); + expect(envelope.code).toBe('PN-CON-4016'); + expect(error.message).toContain('mongo/string@1'); + expect(error.meta).toEqual({ codecId: 'mongo/string@1' }); + }); }); describe('Generic Error', () => { diff --git a/packages/1-framework/1-core/framework-components/src/control/control-stack.ts b/packages/1-framework/1-core/framework-components/src/control/control-stack.ts index d460e64989..13cd068069 100644 --- a/packages/1-framework/1-core/framework-components/src/control/control-stack.ts +++ b/packages/1-framework/1-core/framework-components/src/control/control-stack.ts @@ -319,7 +319,7 @@ export function extractCodecLookup( for (const descriptor of descriptors) { const codecTypes = descriptor.types?.codecTypes; const descriptorId = descriptor.id; - // Descriptor-side metadata is the single source of truth for `targetTypes` / `meta` / `renderOutputType`. Every contributor ships a `codecDescriptors` list on `types.codecTypes`. + // Descriptor-side metadata is the single source of truth for `targetTypes` / `meta` / `renderOutputType`. A component contributes its codecs by listing `codecDescriptors` on `types.codecTypes`; each codecId has exactly one contributor across the stack. for (const codecDescriptor of codecTypes?.codecDescriptors ?? []) { assertUniqueCodecOwner({ codecId: codecDescriptor.codecId, diff --git a/packages/2-mongo-family/1-foundation/mongo-contract/src/contract-schema.ts b/packages/2-mongo-family/1-foundation/mongo-contract/src/contract-schema.ts index c691e7aab8..fbbaefcf22 100644 --- a/packages/2-mongo-family/1-foundation/mongo-contract/src/contract-schema.ts +++ b/packages/2-mongo-family/1-foundation/mongo-contract/src/contract-schema.ts @@ -342,6 +342,14 @@ export const StorageCollectionSchema = type({ 'control?': ControlPolicySchema, }); +export const StorageValueSetSchema = type({ + '+': 'reject', + kind: "'valueSet'", + values: type('string | number | boolean | null | unknown[] | Record') + .array() + .readonly(), +}); + function collectionEntrySchema(fragments?: ReadonlyMap>): Type { if (fragments === undefined || fragments.size === 0) { return StorageCollectionSchema; @@ -389,6 +397,7 @@ export function createMongoNamespaceEnvelopeSchema( 'kind?': 'string', entries: type({ 'collection?': type({ '[string]': collectionEntrySchema(fragments) }), + 'valueSet?': type({ '[string]': StorageValueSetSchema }), }), }).narrow((ns, ctx) => { if (typeof ns !== 'object' || ns === null || Array.isArray(ns)) { diff --git a/packages/2-mongo-family/1-foundation/mongo-contract/src/entity-kinds.ts b/packages/2-mongo-family/1-foundation/mongo-contract/src/entity-kinds.ts index 88dc6933b4..b97a4ae824 100644 --- a/packages/2-mongo-family/1-foundation/mongo-contract/src/entity-kinds.ts +++ b/packages/2-mongo-family/1-foundation/mongo-contract/src/entity-kinds.ts @@ -2,8 +2,9 @@ import type { AnyEntityKindDescriptor, EntityKindDescriptor, } from '@prisma-next/framework-components/ir'; -import { StorageCollectionSchema } from './contract-schema'; +import { StorageCollectionSchema, StorageValueSetSchema } from './contract-schema'; import { MongoCollection, type MongoCollectionInput } from './ir/mongo-collection'; +import { MongoValueSet, type MongoValueSetInput } from './ir/mongo-value-set'; export const collectionEntityKind: EntityKindDescriptor = { kind: 'collection', @@ -11,6 +12,12 @@ export const collectionEntityKind: EntityKindDescriptor new MongoCollection(input), }; +export const valueSetEntityKind: EntityKindDescriptor = { + kind: 'valueSet', + schema: StorageValueSetSchema, + construct: (input) => new MongoValueSet(input), +}; + /** * Assembles the `kind → descriptor` registry for Mongo namespaces: the built-in * `collection` kind plus any target `packKinds`. This builds the lookup table — @@ -21,7 +28,10 @@ export const collectionEntityKind: EntityKindDescriptor { - const kinds = new Map([['collection', collectionEntityKind]]); + const kinds = new Map([ + ['collection', collectionEntityKind], + ['valueSet', valueSetEntityKind], + ]); for (const descriptor of packKinds) { if (kinds.has(descriptor.kind)) { throw new Error( diff --git a/packages/2-mongo-family/1-foundation/mongo-contract/src/exports/entity-kinds.ts b/packages/2-mongo-family/1-foundation/mongo-contract/src/exports/entity-kinds.ts index a8ed18ef00..1de79a4149 100644 --- a/packages/2-mongo-family/1-foundation/mongo-contract/src/exports/entity-kinds.ts +++ b/packages/2-mongo-family/1-foundation/mongo-contract/src/exports/entity-kinds.ts @@ -1 +1,5 @@ -export { collectionEntityKind, composeMongoEntityKinds } from '../entity-kinds'; +export { + collectionEntityKind, + composeMongoEntityKinds, + valueSetEntityKind, +} from '../entity-kinds'; diff --git a/packages/2-mongo-family/1-foundation/mongo-contract/src/exports/index.ts b/packages/2-mongo-family/1-foundation/mongo-contract/src/exports/index.ts index 9c63bf700e..e966c7e18b 100644 --- a/packages/2-mongo-family/1-foundation/mongo-contract/src/exports/index.ts +++ b/packages/2-mongo-family/1-foundation/mongo-contract/src/exports/index.ts @@ -2,6 +2,7 @@ export { createMongoContractSchema, createMongoNamespaceEnvelopeSchema, MongoContractSchema, + StorageValueSetSchema, } from '../contract-schema'; export type { AnyMongoTypeMaps, @@ -82,6 +83,8 @@ export type { MongoValidatorValidationLevel, } from '../ir/mongo-validator'; export { MongoValidator } from '../ir/mongo-validator'; +export type { MongoValueSetInput } from '../ir/mongo-value-set'; +export { MongoValueSet } from '../ir/mongo-value-set'; export type { ApplyScopeResult, PolymorphicIndexScope, diff --git a/packages/2-mongo-family/1-foundation/mongo-contract/src/ir/mongo-storage.ts b/packages/2-mongo-family/1-foundation/mongo-contract/src/ir/mongo-storage.ts index 68a5921821..b4a2e4c1e6 100644 --- a/packages/2-mongo-family/1-foundation/mongo-contract/src/ir/mongo-storage.ts +++ b/packages/2-mongo-family/1-foundation/mongo-contract/src/ir/mongo-storage.ts @@ -6,14 +6,19 @@ import { type Storage, } from '@prisma-next/framework-components/ir'; import type { MongoCollection, MongoCollectionInput } from './mongo-collection'; +import type { MongoValueSet, MongoValueSetInput } from './mongo-value-set'; export type MongoNamespaceEntries = Readonly>>> & { readonly collection?: Readonly>; + readonly valueSet?: Readonly>; }; export interface MongoNamespaceCollectionsInput { readonly id: string; - readonly entries: Readonly>>>; + readonly entries: Readonly>>> & { + readonly collection?: Readonly>; + readonly valueSet?: Readonly>; + }; } export type MongoNamespace = Namespace & { diff --git a/packages/2-mongo-family/1-foundation/mongo-contract/src/ir/mongo-value-set.ts b/packages/2-mongo-family/1-foundation/mongo-contract/src/ir/mongo-value-set.ts new file mode 100644 index 0000000000..7f5a938aad --- /dev/null +++ b/packages/2-mongo-family/1-foundation/mongo-contract/src/ir/mongo-value-set.ts @@ -0,0 +1,32 @@ +import type { JsonValue } from '@prisma-next/contract/types'; +import { freezeNode, IRNodeBase } from '@prisma-next/framework-components/ir'; + +/** + * Hydration / construction input shape for {@link MongoValueSet}. Mirrors the on-disk storage JSON + * envelope (the value held at `contract.storage.namespaces[].entries.valueSet[]`) so the + * serializer hydration walker can hand a validated literal straight to `new`. + */ +export interface MongoValueSetInput { + readonly kind: 'valueSet'; + /** Ordered permitted values, codec-encoded. Declaration order is preserved. */ + readonly values: readonly JsonValue[]; +} + +/** + * Mongo Contract IR node for a value-set entry in a namespace's `valueSet` map + * (`entries.valueSet`). Same shape as SQL's `StorageValueSet`: the ordered set of permitted + * codec-encoded values for a field restriction, with no `codecId` (the field that references it + * holds the codec). The `kind` is enumerable so the JSON envelope carries the discriminator and the + * hydration walker can dispatch on it. Value-sets are keyed by name in the parent namespace's + * `valueSet: Record` map, so the entry name is not on the class. + */ +export class MongoValueSet extends IRNodeBase { + readonly kind = 'valueSet' as const; + readonly values: readonly JsonValue[]; + + constructor(input: MongoValueSetInput) { + super(); + this.values = Object.freeze([...input.values]); + freezeNode(this); + } +} diff --git a/packages/2-mongo-family/1-foundation/mongo-contract/test/entity-kinds.test.ts b/packages/2-mongo-family/1-foundation/mongo-contract/test/entity-kinds.test.ts index b0ded72c9f..9fd0643199 100644 --- a/packages/2-mongo-family/1-foundation/mongo-contract/test/entity-kinds.test.ts +++ b/packages/2-mongo-family/1-foundation/mongo-contract/test/entity-kinds.test.ts @@ -1,7 +1,12 @@ import { hydrateNamespaceEntities } from '@prisma-next/framework-components/ir'; import { describe, expect, it } from 'vitest'; -import { collectionEntityKind, composeMongoEntityKinds } from '../src/entity-kinds'; +import { + collectionEntityKind, + composeMongoEntityKinds, + valueSetEntityKind, +} from '../src/entity-kinds'; import { MongoCollection } from '../src/ir/mongo-collection'; +import { MongoValueSet } from '../src/ir/mongo-value-set'; const minimalCollectionInput = {}; @@ -12,12 +17,25 @@ describe('collectionEntityKind', () => { }); }); +describe('valueSetEntityKind', () => { + it('construct produces MongoValueSet instances', () => { + const result = valueSetEntityKind.construct({ kind: 'valueSet', values: ['admin', 'reader'] }); + expect(result).toBeInstanceOf(MongoValueSet); + expect(result.values).toEqual(['admin', 'reader']); + }); +}); + describe('composeMongoEntityKinds', () => { it('includes collection by default', () => { const kinds = composeMongoEntityKinds(); expect(kinds.has('collection')).toBe(true); }); + it('includes valueSet by default', () => { + const kinds = composeMongoEntityKinds(); + expect(kinds.has('valueSet')).toBe(true); + }); + it('merges pack descriptors', () => { const synth = { kind: 'synthetic', @@ -49,6 +67,19 @@ describe('hydrateNamespaceEntities with Mongo kinds (carry)', () => { expect(result['collection']?.['items']).toBeInstanceOf(MongoCollection); }); + it('constructs valueSet entries from JSON', () => { + const kinds = composeMongoEntityKinds(); + const result = hydrateNamespaceEntities( + { collection: {}, valueSet: { Role: { kind: 'valueSet', values: ['admin', 'reader'] } } }, + kinds, + 'carry', + ); + const roleVs = result['valueSet']?.['Role']; + expect(roleVs).toBeInstanceOf(MongoValueSet); + expect((roleVs as MongoValueSet).values).toEqual(['admin', 'reader']); + expect(Object.isFrozen(roleVs)).toBe(true); + }); + it('carries unknown kinds frozen as-is', () => { const kinds = composeMongoEntityKinds(); const bogusMap = Object.freeze({ foo: { x: 1 } }); diff --git a/packages/2-mongo-family/1-foundation/mongo-contract/test/ir/mongo-value-set.test.ts b/packages/2-mongo-family/1-foundation/mongo-contract/test/ir/mongo-value-set.test.ts new file mode 100644 index 0000000000..2a57d544c1 --- /dev/null +++ b/packages/2-mongo-family/1-foundation/mongo-contract/test/ir/mongo-value-set.test.ts @@ -0,0 +1,36 @@ +import { IRNodeBase } from '@prisma-next/framework-components/ir'; +import { describe, expect, it } from 'vitest'; +import { MongoValueSet } from '../../src/ir/mongo-value-set'; + +describe('MongoValueSet', () => { + it('constructs with ordered values', () => { + const vs = new MongoValueSet({ kind: 'valueSet', values: ['user', 'admin'] }); + expect(vs.kind).toBe('valueSet'); + expect(vs.values).toEqual(['user', 'admin']); + }); + + it('preserves declaration order', () => { + const vs = new MongoValueSet({ kind: 'valueSet', values: ['alpha', 'beta', 'gamma'] }); + expect([...vs.values]).toEqual(['alpha', 'beta', 'gamma']); + }); + + it('keeps non-string encoded values (ints, booleans)', () => { + const vs = new MongoValueSet({ kind: 'valueSet', values: [0, 1, 2] }); + expect(vs.values).toEqual([0, 1, 2]); + }); + + it('extends IRNodeBase and freezes', () => { + const vs = new MongoValueSet({ kind: 'valueSet', values: ['x', 'y'] }); + expect(vs).toBeInstanceOf(IRNodeBase); + expect(vs).toBeInstanceOf(MongoValueSet); + expect(Object.isFrozen(vs)).toBe(true); + }); + + it('round-trips through canonical JSON', () => { + const vs = new MongoValueSet({ kind: 'valueSet', values: ['admin', 'author', 'reader'] }); + expect(JSON.parse(JSON.stringify(vs))).toEqual({ + kind: 'valueSet', + values: ['admin', 'author', 'reader'], + }); + }); +}); diff --git a/packages/2-mongo-family/1-foundation/mongo-contract/test/mongo-storage.test.ts b/packages/2-mongo-family/1-foundation/mongo-contract/test/mongo-storage.test.ts index ccb334c322..25af583aca 100644 --- a/packages/2-mongo-family/1-foundation/mongo-contract/test/mongo-storage.test.ts +++ b/packages/2-mongo-family/1-foundation/mongo-contract/test/mongo-storage.test.ts @@ -10,6 +10,7 @@ import { MongoCollection } from '../src/ir/mongo-collection'; import { MongoIndex } from '../src/ir/mongo-index'; import { MongoStorage } from '../src/ir/mongo-storage'; import { MongoUnboundNamespace } from '../src/ir/mongo-unbound-namespace'; +import { MongoValueSet } from '../src/ir/mongo-value-set'; const hash = coreHash('h_0'); @@ -118,4 +119,49 @@ describe('MongoStorage', () => { expect(ns.entries['bogus']).toBeDefined(); expect(ns).not.toBe(MongoUnboundNamespace.instance); }); + + it('buildMongoNamespace hydrates a valueSet slot into MongoValueSet instances', () => { + const ns = buildMongoNamespace({ + id: 'default', + entries: { + collection: {}, + valueSet: { Role: { kind: 'valueSet', values: ['admin', 'author', 'reader'] } }, + }, + }); + const roleVs = ns.entries['valueSet']?.['Role']; + expect(roleVs).toBeInstanceOf(MongoValueSet); + expect((roleVs as MongoValueSet).values).toEqual(['admin', 'author', 'reader']); + }); + + it('buildMongoNamespace value set survives JSON.stringify round-trip and re-hydrates equal', () => { + const ns = buildMongoNamespace({ + id: 'default', + entries: { + collection: {}, + valueSet: { Role: { kind: 'valueSet', values: ['admin', 'reader'] } }, + }, + }); + const json = JSON.parse(JSON.stringify(ns)) as { entries: Record }; + expect(json.entries['valueSet']).toEqual({ + Role: { kind: 'valueSet', values: ['admin', 'reader'] }, + }); + const rehydrated = buildMongoNamespace({ + id: 'default', + entries: json.entries as never, + }); + expect(rehydrated.entries['valueSet']?.['Role']).toBeInstanceOf(MongoValueSet); + expect((rehydrated.entries['valueSet']?.['Role'] as MongoValueSet).values).toEqual([ + 'admin', + 'reader', + ]); + }); + + it('buildMongoNamespace with unbound id and only a valueSet slot does not return the unbound singleton', () => { + const ns = buildMongoNamespace({ + id: UNBOUND_NAMESPACE_ID, + entries: { valueSet: { Role: { kind: 'valueSet', values: ['admin'] } } }, + }); + expect(ns.entries['valueSet']?.['Role']).toBeInstanceOf(MongoValueSet); + expect(ns).not.toBe(MongoUnboundNamespace.instance); + }); }); diff --git a/packages/2-mongo-family/1-foundation/mongo-contract/test/value-set-schema.test.ts b/packages/2-mongo-family/1-foundation/mongo-contract/test/value-set-schema.test.ts new file mode 100644 index 0000000000..874b8bedd3 --- /dev/null +++ b/packages/2-mongo-family/1-foundation/mongo-contract/test/value-set-schema.test.ts @@ -0,0 +1,66 @@ +import { type } from 'arktype'; +import { describe, expect, it } from 'vitest'; +import { createMongoNamespaceEnvelopeSchema, StorageValueSetSchema } from '../src/contract-schema'; + +describe('StorageValueSetSchema', () => { + it("accepts a value-set with kind 'valueSet' and string values", () => { + const result = StorageValueSetSchema({ kind: 'valueSet', values: ['admin', 'reader'] }); + expect(result).not.toBeInstanceOf(type.errors); + }); + + it('accepts numeric and boolean encoded values', () => { + expect(StorageValueSetSchema({ kind: 'valueSet', values: [0, 1, 2] })).not.toBeInstanceOf( + type.errors, + ); + expect(StorageValueSetSchema({ kind: 'valueSet', values: [true, false] })).not.toBeInstanceOf( + type.errors, + ); + }); + + it('rejects a value-set missing its values array', () => { + expect(StorageValueSetSchema({ kind: 'valueSet' })).toBeInstanceOf(type.errors); + }); + + it('rejects a value-set with the wrong kind', () => { + expect(StorageValueSetSchema({ kind: 'value-set', values: ['a'] })).toBeInstanceOf(type.errors); + }); +}); + +describe('namespace envelope with entries.valueSet', () => { + const envelope = createMongoNamespaceEnvelopeSchema(); + + it('accepts a namespace carrying a valueSet slot alongside collection', () => { + const result = envelope({ + id: '__unbound__', + entries: { + collection: { accounts: { kind: 'mongo-collection' } }, + valueSet: { Role: { kind: 'valueSet', values: ['admin', 'author', 'reader'] } }, + }, + }); + expect(result).not.toBeInstanceOf(type.errors); + }); + + it('accepts a namespace with only a valueSet slot', () => { + const result = envelope({ + id: '__unbound__', + entries: { valueSet: { Role: { kind: 'valueSet', values: ['admin'] } } }, + }); + expect(result).not.toBeInstanceOf(type.errors); + }); + + it('rejects a malformed valueSet entry (missing values)', () => { + const result = envelope({ + id: '__unbound__', + entries: { valueSet: { Role: { kind: 'valueSet' } } }, + }); + expect(result).toBeInstanceOf(type.errors); + }); + + it('rejects a valueSet entry with the wrong kind', () => { + const result = envelope({ + id: '__unbound__', + entries: { valueSet: { Role: { kind: 'collection', values: ['a'] } } }, + }); + expect(result).toBeInstanceOf(type.errors); + }); +}); diff --git a/packages/2-mongo-family/2-authoring/contract-psl/package.json b/packages/2-mongo-family/2-authoring/contract-psl/package.json index 22ae8d3c24..6f77b36f16 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/package.json +++ b/packages/2-mongo-family/2-authoring/contract-psl/package.json @@ -18,6 +18,7 @@ "@prisma-next/config": "workspace:0.14.0", "@prisma-next/contract": "workspace:0.14.0", "@prisma-next/contract-authoring": "workspace:0.14.0", + "@prisma-next/errors": "workspace:0.14.0", "@prisma-next/framework-components": "workspace:0.14.0", "@prisma-next/mongo-contract": "workspace:0.14.0", "@prisma-next/mongo-value": "workspace:0.14.0", diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/derive-json-schema.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/derive-json-schema.ts index 1515f3c44e..198df5b3e8 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/derive-json-schema.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/derive-json-schema.ts @@ -1,7 +1,14 @@ -import type { ContractEnum, ContractField, ContractValueObject } from '@prisma-next/contract/types'; +import type { ContractField, ContractValueObject, JsonValue } from '@prisma-next/contract/types'; import type { CodecLookup } from '@prisma-next/framework-components/codec'; import { MongoValidator } from '@prisma-next/mongo-contract'; +/** + * The permitted values a field's value set restricts it to, keyed by the value set's name — the + * storage `entries.valueSet` slot. The validator's `enum` keyword is sourced from these, not from + * `domain.enum`. + */ +export type FieldValueSets = Record; + function resolveBsonType( codecId: string, codecLookup: CodecLookup | undefined, @@ -13,15 +20,15 @@ function fieldToBsonSchema( field: ContractField, valueObjects: Record | undefined, codecLookup: CodecLookup | undefined, - enums: Record | undefined, + valueSets: FieldValueSets | undefined, ): Record | undefined { if (field.type.kind === 'scalar') { const bsonType = resolveBsonType(field.type.codecId, codecLookup); if (!bsonType) return undefined; const enumValues = - field.valueSet?.entityKind === 'enum' - ? (enums?.[field.valueSet.entityName]?.members.map((m) => m.value) ?? null) + field.valueSet !== undefined + ? (valueSets?.[field.valueSet.entityName]?.values ?? null) : null; if ('many' in field && field.many) { @@ -44,7 +51,7 @@ function fieldToBsonSchema( if (field.type.kind === 'valueObject') { const vo = valueObjects?.[field.type.name]; if (!vo) return undefined; - const voSchema = deriveObjectSchema(vo.fields, valueObjects, codecLookup, enums); + const voSchema = deriveObjectSchema(vo.fields, valueObjects, codecLookup, valueSets); if ('many' in field && field.many) { return { bsonType: 'array', items: voSchema }; } @@ -61,13 +68,13 @@ function deriveObjectSchema( fields: Record, valueObjects: Record | undefined, codecLookup: CodecLookup | undefined, - enums: Record | undefined, + valueSets: FieldValueSets | undefined, ): Record { const properties: Record = {}; const required: string[] = []; for (const [fieldName, field] of Object.entries(fields)) { - const schema = fieldToBsonSchema(field, valueObjects, codecLookup, enums); + const schema = fieldToBsonSchema(field, valueObjects, codecLookup, valueSets); if (schema) { properties[fieldName] = schema; if (!field.nullable) { @@ -95,10 +102,10 @@ export function deriveJsonSchema( fields: Record, valueObjects?: Record, codecLookup?: CodecLookup, - enums?: Record, + valueSets?: FieldValueSets, ): MongoValidator { return new MongoValidator({ - jsonSchema: deriveObjectSchema(fields, valueObjects, codecLookup, enums), + jsonSchema: deriveObjectSchema(fields, valueObjects, codecLookup, valueSets), validationLevel: 'strict', validationAction: 'error', }); @@ -115,9 +122,9 @@ export function derivePolymorphicJsonSchema( variants: readonly PolymorphicVariant[], valueObjects?: Record, codecLookup?: CodecLookup, - enums?: Record, + valueSets?: FieldValueSets, ): MongoValidator { - const baseSchema = deriveObjectSchema(baseFields, valueObjects, codecLookup, enums); + const baseSchema = deriveObjectSchema(baseFields, valueObjects, codecLookup, valueSets); const baseProperties = isRecord(baseSchema['properties']) ? baseSchema['properties'] : {}; const oneOf: Record[] = []; @@ -132,7 +139,7 @@ export function derivePolymorphicJsonSchema( const variantProperties: Record = {}; const variantRequired: string[] = [discriminatorField]; for (const [name, field] of Object.entries(variantOnlyFields)) { - const schema = fieldToBsonSchema(field, valueObjects, codecLookup, enums); + const schema = fieldToBsonSchema(field, valueObjects, codecLookup, valueSets); if (schema) { variantProperties[name] = schema; if (!field.nullable) { diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/exports/index.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/exports/index.ts index 2cefc0aa94..5027d231ce 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/exports/index.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/exports/index.ts @@ -1,4 +1,8 @@ -export { deriveJsonSchema, derivePolymorphicJsonSchema } from '../derive-json-schema'; +export { + deriveJsonSchema, + derivePolymorphicJsonSchema, + type FieldValueSets, +} from '../derive-json-schema'; export { type InterpretPslDocumentToMongoContractInput, interpretPslDocumentToMongoContract, diff --git a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts index ca17481318..cbdc3ffd2b 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/src/interpreter.ts @@ -15,6 +15,7 @@ import { type ValueSetRef, } from '@prisma-next/contract/types'; import type { EnumTypeHandle } from '@prisma-next/contract-authoring'; +import { errorEnumCodecNotInPackStack } from '@prisma-next/errors/control'; import type { AuthoringContributions, AuthoringEntityContext, @@ -32,6 +33,7 @@ import { MongoIndex, type MongoIndexKeyDirection, MongoStorage, + type MongoValueSetInput, } from '@prisma-next/mongo-contract'; import { mongoContractCanonicalizationHooks } from '@prisma-next/mongo-contract/canonicalization-hooks'; import type { CollationOptions } from '@prisma-next/mongo-value/mongodb-types'; @@ -47,6 +49,7 @@ import type { } from '@prisma-next/psl-parser'; import { nodePslSpan } from '@prisma-next/psl-parser'; import type { SourceFile } from '@prisma-next/psl-parser/syntax'; +import { assertDefined } from '@prisma-next/utils/assertions'; import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import { notOk, ok, type Result } from '@prisma-next/utils/result'; @@ -62,6 +65,21 @@ import { parseRelationAttribute, } from './psl-helpers'; +/** + * Encode an authored enum value to its codec-encoded JSON form via the codec resolved by id from the + * contract's codec lookup, so a non-identity `encodeJson` (permitted by the `mongoCodec` factory) is + * respected. Matches the TS builder's `encodeEnumValue`: the lookup is always threaded in production, + * and a codecId the lookup cannot resolve is a hard error — the enum uses a codec that is not part of + * the contract's pack stack. + */ +function encodeEnumValue(value: unknown, codecId: string, codecLookup: CodecLookup): JsonValue { + const codec = codecLookup.get(codecId); + if (!codec) { + throw errorEnumCodecNotInPackStack({ codecId }); + } + return codec.encodeJson(value); +} + export interface InterpretPslDocumentToMongoContractInput { readonly symbolTable: SymbolTable; readonly sourceFile: SourceFile; @@ -1213,6 +1231,28 @@ export function interpretPslDocumentToMongoContract( const resolvedModels = polyResult.models; const resolvedCollections = polyResult.collections; + // The storage value set is the source of truth for both the emit typing and the validator's + // `enum` keyword. Built once, ahead of validator derivation, from each enum's codec-encoded member + // values (mirroring SQL's build-contract). Encoding needs the codec lookup; production always + // threads it (the CLI control stack supplies it), so its absence when enums exist is a wiring bug, + // not a runtime input to tolerate. + const storageValueSets: Record = {}; + const enumEntries = Object.entries(builtEnums); + if (enumEntries.length > 0) { + assertDefined( + codecLookup, + 'Mongo PSL interpretation requires a codec lookup to encode enum values', + ); + for (const [enumName, builtEnum] of enumEntries) { + storageValueSets[enumName] = { + kind: 'valueSet', + values: builtEnum.members.map((m) => + encodeEnumValue(m.value, builtEnum.codecId, codecLookup), + ), + }; + } + } + for (const [, modelEntry] of Object.entries(resolvedModels)) { if (modelEntry.base) continue; @@ -1233,14 +1273,14 @@ export function interpretPslDocumentToMongoContract( variantEntries, valueObjects, codecLookup, - builtEnums, + storageValueSets, ); } else { coll['validator'] = deriveJsonSchema( modelEntry.fields, valueObjects, codecLookup, - builtEnums, + storageValueSets, ); } } @@ -1258,18 +1298,27 @@ export function interpretPslDocumentToMongoContract( 'arktype-validated JSON shapes satisfy MongoCollectionInput by construction' >(raw); } + const hasValueSets = Object.keys(storageValueSets).length > 0; + const unboundNamespace = buildMongoNamespace({ id: UNBOUND_NAMESPACE_ID, - entries: { collection: collectionInputs }, + entries: { + collection: collectionInputs, + ...(hasValueSets ? { valueSet: storageValueSets } : {}), + }, }); - // Hash the constructed (normalized) collection map, not the raw input - // literals — persisted storageHash values were computed over the - // constructed form. + // Hash the constructed (normalized) entries, not the raw input literals — + // persisted storageHash values were computed over the constructed form. const storageWithoutHash = { namespaces: { [UNBOUND_NAMESPACE_ID]: { id: UNBOUND_NAMESPACE_ID, - entries: { collection: unboundNamespace.entries.collection }, + entries: { + collection: unboundNamespace.entries.collection, + ...(unboundNamespace.entries.valueSet !== undefined + ? { valueSet: unboundNamespace.entries.valueSet } + : {}), + }, }, }, }; diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/derive-json-schema.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/derive-json-schema.test.ts index 5570fac783..679a998898 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/derive-json-schema.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/derive-json-schema.test.ts @@ -1,7 +1,11 @@ -import type { ContractEnum, ContractField, ContractValueObject } from '@prisma-next/contract/types'; +import type { ContractField, ContractValueObject } from '@prisma-next/contract/types'; import type { CodecLookup } from '@prisma-next/framework-components/codec'; import { describe, expect, it } from 'vitest'; -import { deriveJsonSchema, derivePolymorphicJsonSchema } from '../src/derive-json-schema'; +import { + deriveJsonSchema, + derivePolymorphicJsonSchema, + type FieldValueSets, +} from '../src/derive-json-schema'; const mongoTargetTypes: Record = { 'mongo/string@1': ['string'], @@ -483,15 +487,7 @@ describe('derivePolymorphicJsonSchema', () => { }); describe('deriveJsonSchema — enum fields', () => { - const roleEnum: ContractEnum = { - codecId: 'mongo/string@1', - members: [ - { name: 'User', value: 'user' }, - { name: 'Admin', value: 'admin' }, - ], - }; - - const enums: Record = { Role: roleEnum }; + const valueSets: FieldValueSets = { Role: { values: ['user', 'admin'] } }; it('emits enum: [...values] in declaration order for an enum-valueSet field', () => { const result = deriveJsonSchema( @@ -501,7 +497,7 @@ describe('deriveJsonSchema — enum fields', () => { }, undefined, mongoCodecLookup, - enums, + valueSets, ); const props = result.jsonSchema['properties'] as Record>; @@ -517,7 +513,7 @@ describe('deriveJsonSchema — enum fields', () => { }, undefined, mongoCodecLookup, - enums, + valueSets, ); const props = result.jsonSchema['properties'] as Record>; @@ -533,7 +529,7 @@ describe('deriveJsonSchema — enum fields', () => { }, undefined, mongoCodecLookup, - enums, + valueSets, ); const props = result.jsonSchema['properties'] as Record>; @@ -548,7 +544,7 @@ describe('deriveJsonSchema — enum fields', () => { }, undefined, mongoCodecLookup, - enums, + valueSets, ); const props = result.jsonSchema['properties'] as Record>; @@ -567,7 +563,7 @@ describe('deriveJsonSchema — enum fields', () => { }, undefined, mongoCodecLookup, - enums, + valueSets, ); const props = result.jsonSchema['properties'] as Record>; @@ -585,15 +581,6 @@ describe('deriveJsonSchema — enum fields', () => { }); it('preserves member value declaration order', () => { - const orderedEnum: ContractEnum = { - codecId: 'mongo/string@1', - members: [ - { name: 'C', value: 'c' }, - { name: 'A', value: 'a' }, - { name: 'B', value: 'b' }, - ], - }; - const result = deriveJsonSchema( { _id: scalarField('mongo/objectId@1'), @@ -601,14 +588,14 @@ describe('deriveJsonSchema — enum fields', () => { }, undefined, mongoCodecLookup, - { Status: orderedEnum }, + { Status: { values: ['c', 'a', 'b'] } }, ); const props = result.jsonSchema['properties'] as Record>; expect(props['status']?.['enum']).toEqual(['c', 'a', 'b']); }); - it('skips enum injection when enumResolver does not contain the referenced enum', () => { + it('skips enum injection when the value set does not contain the referenced entry', () => { const result = deriveJsonSchema( { _id: scalarField('mongo/objectId@1'), @@ -616,11 +603,32 @@ describe('deriveJsonSchema — enum fields', () => { }, undefined, mongoCodecLookup, - enums, + valueSets, ); const props = result.jsonSchema['properties'] as Record>; expect(props['role']).toEqual({ bsonType: 'string' }); expect(props['role']).not.toHaveProperty('enum'); }); + + it('sources the enum keyword from the value set only (a differing domain enum is never consulted)', () => { + // The deriver takes a value-set map and nothing else — it cannot read `domain.enum`. Passing a + // value set whose values differ from any domain enum proves the `enum` keyword follows the value + // set, not the domain enum. + const result = deriveJsonSchema( + { + _id: scalarField('mongo/objectId@1'), + role: enumField('mongo/string@1', 'Role'), + }, + undefined, + mongoCodecLookup, + { Role: { values: ['from-value-set-a', 'from-value-set-b'] } }, + ); + + const props = result.jsonSchema['properties'] as Record>; + expect(props['role']).toEqual({ + bsonType: 'string', + enum: ['from-value-set-a', 'from-value-set-b'], + }); + }); }); diff --git a/packages/2-mongo-family/2-authoring/contract-ts/package.json b/packages/2-mongo-family/2-authoring/contract-ts/package.json index 0b1cc5706d..f2e160bb40 100644 --- a/packages/2-mongo-family/2-authoring/contract-ts/package.json +++ b/packages/2-mongo-family/2-authoring/contract-ts/package.json @@ -19,6 +19,7 @@ "@prisma-next/config": "workspace:0.14.0", "@prisma-next/contract": "workspace:0.14.0", "@prisma-next/contract-authoring": "workspace:0.14.0", + "@prisma-next/errors": "workspace:0.14.0", "@prisma-next/framework-components": "workspace:0.14.0", "@prisma-next/mongo-contract": "workspace:0.14.0", "@prisma-next/utils": "workspace:0.14.0", diff --git a/packages/2-mongo-family/2-authoring/contract-ts/src/contract-builder.ts b/packages/2-mongo-family/2-authoring/contract-ts/src/contract-builder.ts index b1099ede23..440bfdb57e 100644 --- a/packages/2-mongo-family/2-authoring/contract-ts/src/contract-builder.ts +++ b/packages/2-mongo-family/2-authoring/contract-ts/src/contract-builder.ts @@ -21,17 +21,20 @@ import { type ExtractAuthoringNamespaceFromPack, type MergeExtensionAuthoringNamespaces, } from '@prisma-next/contract-authoring'; +import { errorEnumCodecNotInPackStack } from '@prisma-next/errors/control'; import type { AuthoringEntityTypeNamespace } from '@prisma-next/framework-components/authoring'; import { assertNoCrossRegistryCollisions, isAuthoringEntityTypeDescriptor, mergeAuthoringNamespaces, } from '@prisma-next/framework-components/authoring'; +import type { CodecLookup } from '@prisma-next/framework-components/codec'; import type { ExtensionPackRef, FamilyPackRef, TargetPackRef, } from '@prisma-next/framework-components/components'; +import { extractCodecLookup } from '@prisma-next/framework-components/control'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { applyPolymorphicScopeToMongoIndex, @@ -51,6 +54,7 @@ import { MongoStorage, type MongoStorageShape, type MongoTypeMaps, + type MongoValueSetInput, } from '@prisma-next/mongo-contract'; import { mongoContractCanonicalizationHooks } from '@prisma-next/mongo-contract/canonicalization-hooks'; import { canonicalStringify } from '@prisma-next/utils/canonical-stringify'; @@ -58,6 +62,22 @@ import { blindCast } from '@prisma-next/utils/casts'; import { ifDefined } from '@prisma-next/utils/defined'; import type { EnumTypeHandle } from './enum-type'; +/** + * Encode an authored enum value to its codec-encoded JSON form, mirroring SQL's `build-contract`. + * + * The codec is resolved by id from the lookup built from the contract's target pack, so a + * non-identity `encodeJson` (permitted by the `mongoCodec` factory) is respected. A codecId the + * lookup cannot resolve is a hard error: the enum uses a codec that is not part of the contract's + * pack stack. + */ +function encodeEnumValue(value: unknown, codecId: string, codecLookup: CodecLookup): JsonValue { + const codec = codecLookup.get(codecId); + if (!codec) { + throw errorEnumCodecNotInPackStack({ codecId }); + } + return codec.encodeJson(value); +} + // `canonicalStringify` rejects non-plain objects so a `Map` or class // instance cannot silently collapse to `{}`. The storage-shape values // produced by `toStorageIndex` post-M2-R2 are `MongoIndex` class @@ -1764,18 +1784,36 @@ function buildContractFromDefinition< // at `hash({})`. const capabilities: Record> = {}; const collections = buildCollections(definition.models); + + // Resolve the target's codecs by id from the pack the contract binds, then encode each enum's + // member values through `codec.encodeJson` — the same real codecs the runtime/control stacks use. + const codecLookup = extractCodecLookup([definition.target]); + + // The value set stores each enum's codec-encoded member values (mirroring SQL's build-contract). + const storageValueSets: Record = {}; + for (const [enumName, handle] of Object.entries(definition.enums ?? {})) { + storageValueSets[enumName] = { + kind: 'valueSet', + values: handle.values.map((v) => encodeEnumValue(v, handle.codecId, codecLookup)), + }; + } + const hasValueSets = Object.keys(storageValueSets).length > 0; + const unboundNamespace = buildMongoNamespace({ id: UNBOUND_NAMESPACE_ID, - entries: { collection: collections }, + entries: { + collection: collections, + ...ifDefined('valueSet', hasValueSets ? storageValueSets : undefined), + }, }); - // Hash the constructed (normalized) collection map, not the raw input - // literals — persisted storageHash values were computed over the - // constructed form. + // Hash the constructed (normalized) entries, not the raw input literals — + // persisted storageHash values were computed over the constructed form. const storageBody = { namespaces: { [UNBOUND_NAMESPACE_ID]: { id: UNBOUND_NAMESPACE_ID, collections: unboundNamespace.entries.collection, + ...ifDefined('valueSet', unboundNamespace.entries.valueSet), }, }, }; @@ -1805,10 +1843,7 @@ function buildContractFromDefinition< codecId: handle.codecId, members: handle.enumMembers.map((m) => ({ name: m.name, - value: blindCast< - JsonValue, - 'enum member values are codec inputs (string/number/bool) and are always JsonValue-compatible' - >(m.value), + value: encodeEnumValue(m.value, handle.codecId, codecLookup), })), }; } diff --git a/packages/2-mongo-family/2-authoring/contract-ts/test/enum-type.authoring.test.ts b/packages/2-mongo-family/2-authoring/contract-ts/test/enum-type.authoring.test.ts index caf1e1c2d7..a3bc149041 100644 --- a/packages/2-mongo-family/2-authoring/contract-ts/test/enum-type.authoring.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-ts/test/enum-type.authoring.test.ts @@ -1,3 +1,5 @@ +import type { AnyCodecDescriptor, Codec } from '@prisma-next/framework-components/codec'; +import { voidParamsSchema } from '@prisma-next/framework-components/codec'; import type { FamilyPackRef, TargetPackRef } from '@prisma-next/framework-components/components'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import { MongoContractSchema } from '@prisma-next/mongo-contract'; @@ -13,6 +15,22 @@ const mongoFamilyPack = { version: '0.0.1', } as const satisfies FamilyPackRef<'mongo'>; +const identityDescriptor = (id: string): AnyCodecDescriptor => ({ + codecId: id, + traits: ['equality'], + targetTypes: ['string'], + paramsSchema: voidParamsSchema, + isParameterized: false, + factory: () => () => + ({ + id, + encode: async (v: unknown) => v, + decode: async (v: unknown) => v, + encodeJson: (v: unknown) => v, + decodeJson: (j: unknown) => j, + }) as unknown as Codec, +}); + const mongoTargetPack = { kind: 'target', id: 'mongo', @@ -20,6 +38,7 @@ const mongoTargetPack = { targetId: 'mongo', version: '0.0.1', defaultNamespaceId: '__unbound__', + types: { codecTypes: { codecDescriptors: [identityDescriptor('mongo/string@1')] } }, } as const satisfies TargetPackRef<'mongo', 'mongo'>; const mongoString = { codecId: 'mongo/string@1' as const, nativeType: 'string' } as const; @@ -116,6 +135,22 @@ describe('builder accumulation + contract-schema acceptance', () => { }); }); + it('emits the storage value set alongside the domain enum', () => { + const envelope = JSON.parse(JSON.stringify(contract)) as { + storage: { + namespaces: Record< + string, + { entries: { valueSet?: Record } } + >; + }; + }; + const ns = envelope.storage.namespaces[UNBOUND_NAMESPACE_ID]; + expect(ns?.entries.valueSet?.['Role']).toEqual({ + kind: 'valueSet', + values: ['user', 'admin'], + }); + }); + it('passes Mongo arktype contract-schema validation', () => { const envelope = JSON.parse(JSON.stringify(contract)) as unknown; const result = MongoContractSchema(envelope); @@ -197,3 +232,80 @@ describe('defineContract() — undeclared enum reference', () => { ).toThrow('references enum "Role" which is not declared'); }); }); + +describe('defineContract() — codec-encoded value set', () => { + const upperCodec = { codecId: 'test/upper@1' as const, nativeType: 'string' } as const; + const upperDescriptor: AnyCodecDescriptor = { + codecId: 'test/upper@1', + traits: ['equality'], + targetTypes: ['string'], + paramsSchema: voidParamsSchema, + isParameterized: false, + factory: () => () => + ({ + id: 'test/upper@1', + encode: async (v: unknown) => v, + decode: async (v: unknown) => v, + encodeJson: (v: unknown) => (v as string).toUpperCase(), + decodeJson: (j: unknown) => j, + }) as unknown as Codec, + }; + const packWithEncoders = { + ...mongoTargetPack, + types: { codecTypes: { codecDescriptors: [upperDescriptor] } }, + } satisfies TargetPackRef<'mongo', 'mongo'>; + + const Role = enumType('Role', upperCodec, member('Admin', 'admin'), member('Author', 'author')); + const Account = model('Account', { + collection: 'accounts', + fields: { _id: field.objectId(), role: field.namedType(Role) }, + }); + + const contract = defineContract({ + family: mongoFamilyPack, + target: packWithEncoders, + enums: { Role }, + models: { Account }, + }); + + it('encodes storage value-set values through the codec', () => { + const envelope = JSON.parse(JSON.stringify(contract)) as { + storage: { + namespaces: Record< + string, + { entries: { valueSet?: Record } } + >; + }; + }; + const ns = envelope.storage.namespaces[UNBOUND_NAMESPACE_ID]; + expect(ns?.entries.valueSet?.['Role']?.values).toEqual(['ADMIN', 'AUTHOR']); + }); + + it('encodes domain enum member values through the codec', () => { + const ns = contract.domain.namespaces[UNBOUND_NAMESPACE_ID]; + const enumSlot = (ns as Record)['enum'] as Record; + expect(enumSlot['Role']).toEqual({ + codecId: 'test/upper@1', + members: [ + { name: 'Admin', value: 'ADMIN' }, + { name: 'Author', value: 'AUTHOR' }, + ], + }); + }); + + it('throws a clear error when the enum codec is not in the pack surface', () => { + const Missing = enumType('Missing', upperCodec, member('One', 'one')); + const WithMissing = model('WithMissing', { + collection: 'withMissing', + fields: { _id: field.objectId(), value: field.namedType(Missing) }, + }); + expect(() => + defineContract({ + family: mongoFamilyPack, + target: mongoTargetPack, + enums: { Missing }, + models: { WithMissing }, + }), + ).toThrow('test/upper@1'); + }); +}); diff --git a/packages/2-mongo-family/3-tooling/emitter/package.json b/packages/2-mongo-family/3-tooling/emitter/package.json index f60219b38c..d7d3ad6b09 100644 --- a/packages/2-mongo-family/3-tooling/emitter/package.json +++ b/packages/2-mongo-family/3-tooling/emitter/package.json @@ -19,7 +19,8 @@ "@prisma-next/contract": "workspace:0.14.0", "@prisma-next/emitter": "workspace:0.14.0", "@prisma-next/framework-components": "workspace:0.14.0", - "@prisma-next/mongo-contract": "workspace:0.14.0" + "@prisma-next/mongo-contract": "workspace:0.14.0", + "@prisma-next/utils": "workspace:0.14.0" }, "devDependencies": { "@prisma-next/test-utils": "workspace:0.14.0", diff --git a/packages/2-mongo-family/3-tooling/emitter/src/index.ts b/packages/2-mongo-family/3-tooling/emitter/src/index.ts index 1ac882d6ba..f3168995d0 100644 --- a/packages/2-mongo-family/3-tooling/emitter/src/index.ts +++ b/packages/2-mongo-family/3-tooling/emitter/src/index.ts @@ -8,6 +8,7 @@ import { serializeObjectKey, serializeValue } from '@prisma-next/emitter/domain- import type { ValidationContext } from '@prisma-next/framework-components/emission'; import type { Namespace } from '@prisma-next/framework-components/ir'; import type { MongoCollection, MongoStorage } from '@prisma-next/mongo-contract'; +import { blindCast } from '@prisma-next/utils/casts'; const MONGO_NAMESPACE_KIND_FALLBACK = 'mongo-namespace' as const; @@ -254,11 +255,6 @@ export const mongoEmission = { return parts.length > 0 ? `{ ${parts.join('; ')} }` : 'Record'; }, - // INTERIM (TML-2953): Mongo has no storage value-set entity yet, so a value-set field's permitted - // values are sourced from `domain.enum`. This is the only remaining domain-enum typing reader in - // the repo; TML-2953 adds Mongo's value set and deletes this, routing the field type through - // storage. The values still flow through the codec seam (`renderValueLiteral`), so this keeps - // Mongo's emitted field types byte-identical without re-introducing a direct render. resolveFieldValueSet( _modelName: string, fieldName: string, @@ -266,14 +262,18 @@ export const mongoEmission = { contract: Contract, ): { readonly encodedValues: readonly JsonValue[]; readonly codecId: string } | undefined { const field = model.fields[fieldName]; - const ref = field?.valueSet; - if (ref?.entityKind !== 'enum') return undefined; - const domainNs = contract.domain.namespaces[ref.namespaceId]; - const domainEnum = domainNs?.enum?.[ref.entityName]; - if (!domainEnum) return undefined; + if (field === undefined || field.type.kind !== 'scalar') return undefined; + const ref = field.valueSet; + if (ref === undefined) return undefined; + const storage = blindCast< + MongoStorage, + 'contract.storage is MongoStorage for the mongo family' + >(contract.storage); + const valueSet = storage.namespaces[ref.namespaceId]?.entries.valueSet?.[ref.entityName]; + if (valueSet === undefined) return undefined; return { - encodedValues: domainEnum.members.map((m) => m.value), - codecId: domainEnum.codecId, + encodedValues: valueSet.values, + codecId: field.type.codecId, }; }, diff --git a/packages/2-mongo-family/3-tooling/emitter/test/emitter-hook.resolve-field-value-set.test.ts b/packages/2-mongo-family/3-tooling/emitter/test/emitter-hook.resolve-field-value-set.test.ts new file mode 100644 index 0000000000..ff79f04865 --- /dev/null +++ b/packages/2-mongo-family/3-tooling/emitter/test/emitter-hook.resolve-field-value-set.test.ts @@ -0,0 +1,74 @@ +import type { Contract, ContractModelBase } from '@prisma-next/contract/types'; +import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +import { describe, expect, it } from 'vitest'; +import { mongoEmission } from '../src/index'; +import { createMongoContract } from './fixtures/create-mongo-contract'; + +const roleField = { + nullable: false, + type: { kind: 'scalar', codecId: 'mongo/string@1' }, + valueSet: { + plane: 'domain', + entityKind: 'enum', + namespaceId: UNBOUND_NAMESPACE_ID, + entityName: 'Role', + }, +} as const; + +const userModel: ContractModelBase = { + fields: { + _id: { nullable: false, type: { kind: 'scalar', codecId: 'mongo/objectId@1' } }, + role: roleField, + }, + relations: {}, + storage: { collection: 'users' }, +} as unknown as ContractModelBase; + +function contractWithValueSet(values: readonly string[]): Contract { + const base = createMongoContract({ models: { User: userModel } }); + const storage = { + storageHash: 'sha256:test', + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + id: UNBOUND_NAMESPACE_ID, + kind: 'mongo-namespace' as const, + entries: { + collection: {}, + valueSet: { Role: { kind: 'valueSet', values } }, + }, + }, + }, + }; + return { ...base, storage: storage as Contract['storage'] }; +} + +describe('mongoEmission.resolveFieldValueSet', () => { + it('sources encodedValues from the storage value set and codecId from the field', () => { + const contract = contractWithValueSet(['admin', 'author', 'reader']); + const resolved = mongoEmission.resolveFieldValueSet?.('User', 'role', userModel, contract); + expect(resolved).toEqual({ + encodedValues: ['admin', 'author', 'reader'], + codecId: 'mongo/string@1', + }); + }); + + it('reads the storage value set, not domain.enum (differing values follow the value set)', () => { + // The domain enum for Role (from `createMongoContract`'s applicationDomainOf) is absent; the only + // source of permitted values is the storage value set. Distinct sentinel values prove the origin. + const contract = contractWithValueSet(['storage-a', 'storage-b']); + const resolved = mongoEmission.resolveFieldValueSet?.('User', 'role', userModel, contract); + expect(resolved?.encodedValues).toEqual(['storage-a', 'storage-b']); + }); + + it('returns undefined for a field with no value-set ref', () => { + const contract = contractWithValueSet(['admin']); + const resolved = mongoEmission.resolveFieldValueSet?.('User', '_id', userModel, contract); + expect(resolved).toBeUndefined(); + }); + + it('returns undefined when the storage value set is absent for the referenced entry', () => { + const base = createMongoContract({ models: { User: userModel } }); + const resolved = mongoEmission.resolveFieldValueSet?.('User', 'role', userModel, base); + expect(resolved).toBeUndefined(); + }); +}); diff --git a/packages/2-mongo-family/3-tooling/emitter/test/emitter-hook.value-set-codec-path.test.ts b/packages/2-mongo-family/3-tooling/emitter/test/emitter-hook.value-set-codec-path.test.ts new file mode 100644 index 0000000000..d73f8fbfa3 --- /dev/null +++ b/packages/2-mongo-family/3-tooling/emitter/test/emitter-hook.value-set-codec-path.test.ts @@ -0,0 +1,108 @@ +import type { Contract, ContractModelBase } from '@prisma-next/contract/types'; +import { generateContractDts } from '@prisma-next/emitter'; +import type { CodecLookup } from '@prisma-next/framework-components/codec'; +import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; +import { describe, expect, it } from 'vitest'; +import { mongoEmission } from '../src/index'; +import { createMongoContract } from './fixtures/create-mongo-contract'; + +const NON_IDENTITY_CODEC_ID = 'test/level@1'; +const LEVEL_BY_INDEX = ['low', 'high', 'urgent'] as const; + +// A non-identity codec: encodes to ints 0|1|2 (the value-set's stored form) but its output type is +// the string literals 'low'|'high'|'urgent'. `renderValueLiteralFor` decodes the encoded int, then +// renders the decoded literal — so the emitted type is the codec OUTPUT, not the encoded value. +const nonIdentityCodecLookup: CodecLookup = { + get: () => undefined, + targetTypesFor: (id) => (id === NON_IDENTITY_CODEC_ID ? ['string'] : undefined), + metaFor: () => undefined, + renderOutputTypeFor: (id) => (id === NON_IDENTITY_CODEC_ID ? 'Level' : undefined), + renderValueLiteralFor: (id, value) => { + if (id !== NON_IDENTITY_CODEC_ID || typeof value !== 'number') return undefined; + const decoded = LEVEL_BY_INDEX[value]; + return decoded === undefined ? undefined : `'${decoded}'`; + }, +}; + +const testHashes = { storageHash: 'sha256:test', profileHash: 'sha256:test' }; + +const levelField = { + nullable: false, + type: { kind: 'scalar', codecId: NON_IDENTITY_CODEC_ID }, + valueSet: { + plane: 'domain', + entityKind: 'enum', + namespaceId: UNBOUND_NAMESPACE_ID, + entityName: 'Level', + }, +} as const; + +const itemModel: ContractModelBase = { + fields: { level: levelField }, + relations: {}, + storage: { collection: 'items' }, +} as unknown as ContractModelBase; + +function contractWithEncodedValueSet(): Contract { + const base = createMongoContract({ models: { Item: itemModel } }); + const storage = { + storageHash: 'sha256:test', + namespaces: { + [UNBOUND_NAMESPACE_ID]: { + id: UNBOUND_NAMESPACE_ID, + kind: 'mongo-namespace' as const, + entries: { + collection: {}, + // Encoded form: integers. The codec decodes them to string literals. + valueSet: { Level: { kind: 'valueSet', values: [0, 1, 2] } }, + }, + }, + }, + }; + return { ...base, storage: storage as Contract['storage'] }; +} + +describe('mongo emit typing routes through the codec seam (not a raw value print)', () => { + it("types a value-set field as the codec's decoded output, not the encoded values", () => { + const dts = generateContractDts( + contractWithEncodedValueSet(), + mongoEmission, + [], + testHashes, + undefined, + nonIdentityCodecLookup, + ); + + const outputMap = dts.slice( + dts.indexOf('export type FieldOutputTypes'), + dts.indexOf('export type FieldInputTypes'), + ); + + // Codec OUTPUT (decoded literals), proving the value flowed through + // renderValueSetType -> renderValueLiteralFor, not a raw print of the encoded values. + expect(outputMap).toContain("readonly level: 'low' | 'high' | 'urgent'"); + expect(outputMap).not.toContain('0 | 1 | 2'); + }); + + it('falls back to the codec output type when the lookup renders no literal', () => { + const noLiteralLookup: CodecLookup = { + ...nonIdentityCodecLookup, + renderValueLiteralFor: () => undefined, + }; + const dts = generateContractDts( + contractWithEncodedValueSet(), + mongoEmission, + [], + testHashes, + undefined, + noLiteralLookup, + ); + const outputMap = dts.slice( + dts.indexOf('export type FieldOutputTypes'), + dts.indexOf('export type FieldInputTypes'), + ); + // Fallback is the codec output channel, never the raw encoded ints. + expect(outputMap).toContain(`readonly level: CodecTypes['${NON_IDENTITY_CODEC_ID}']['output']`); + expect(outputMap).not.toContain('0 | 1 | 2'); + }); +}); diff --git a/packages/2-mongo-family/7-runtime/test/mongo-execution-stack.test.ts b/packages/2-mongo-family/7-runtime/test/mongo-execution-stack.test.ts index edac2545e8..27ba734156 100644 --- a/packages/2-mongo-family/7-runtime/test/mongo-execution-stack.test.ts +++ b/packages/2-mongo-family/7-runtime/test/mongo-execution-stack.test.ts @@ -161,9 +161,9 @@ describe('createMongoExecutionContext', () => { }); describe('runtime adapter descriptor', () => { - it('declares the seven standard Mongo codec descriptors on codecDescriptors', () => { - const codecDescriptors = mongoRuntimeAdapter.types?.codecTypes?.codecDescriptors ?? []; - expect(codecDescriptors.map((d) => d.codecId).sort()).toEqual([...STANDARD_CODEC_IDS].sort()); + it('surfaces the seven standard Mongo codecs through its codecs() registry', () => { + const codecIds = [...mongoRuntimeAdapter.codecs()].map((codec) => codec.id).sort(); + expect(codecIds).toEqual([...STANDARD_CODEC_IDS].sort()); }); it('create(stack) returns an instance whose lower() delegates to the standard adapter', async () => { diff --git a/packages/3-extensions/mongo/test/mongo.enum.e2e.test.ts b/packages/3-extensions/mongo/test/mongo.enum.e2e.test.ts index 4db7864c49..c18e64e1d3 100644 --- a/packages/3-extensions/mongo/test/mongo.enum.e2e.test.ts +++ b/packages/3-extensions/mongo/test/mongo.enum.e2e.test.ts @@ -2,7 +2,7 @@ import { generateContractDts } from '@prisma-next/emitter'; import type { CodecLookup } from '@prisma-next/framework-components/codec'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import type { ExtractMongoFieldOutputTypes } from '@prisma-next/mongo-contract'; -import { deriveJsonSchema } from '@prisma-next/mongo-contract-psl'; +import { deriveJsonSchema, type FieldValueSets } from '@prisma-next/mongo-contract-psl'; import { mongoEmission } from '@prisma-next/mongo-emitter'; import { timeouts } from '@prisma-next/test-utils'; import { blindCast } from '@prisma-next/utils/casts'; @@ -75,17 +75,18 @@ const codecLookup: CodecLookup = { // Derive the $jsonSchema validator from the contract via the production deriver. // This ensures a regression in deriveJsonSchema (e.g. placing enum outside items // for array fields) breaks this test — not just the unit tests in derive-json-schema.test.ts. +// The validator's `enum` keyword is sourced from the storage value set (as production does), not +// from `domain.enum`. const ns = contract.domain.namespaces[UNBOUND_NAMESPACE_ID]; const accountFields = ns?.models['Account']?.fields ?? {}; -const ACCOUNT_VALIDATOR = deriveJsonSchema( - accountFields, - undefined, - codecLookup, - blindCast< - Record, - 'enum member values are JsonValue-compatible at runtime; namespace enum? slot types values as a union not JsonValue' - >(ns?.enum), +const storageValueSets = blindCast< + FieldValueSets, + 'a mongo storage namespace exposes entries.valueSet as { : { values } } after authoring' +>( + (contract.storage as { namespaces: Record }) + .namespaces[UNBOUND_NAMESPACE_ID]?.entries.valueSet ?? {}, ); +const ACCOUNT_VALIDATOR = deriveJsonSchema(accountFields, undefined, codecLookup, storageValueSets); describe('mongo enum — end-to-end (replica set)', { timeout: timeouts.spinUpMongoMemoryServer, diff --git a/packages/3-mongo-target/1-mongo-target/src/core/descriptor-meta.ts b/packages/3-mongo-target/1-mongo-target/src/core/descriptor-meta.ts index 12676a95c6..de30ea7fa2 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/descriptor-meta.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/descriptor-meta.ts @@ -1,6 +1,11 @@ +import { mongoCodecDescriptors } from '@prisma-next/adapter-mongo/codecs'; +import type { TargetPackRef } from '@prisma-next/framework-components/components'; import { UNBOUND_NAMESPACE_ID } from '@prisma-next/framework-components/ir'; import type { CodecTypes } from './codec-types'; +// The Mongo target owns its codec descriptors. Contract authoring resolves each enum's codec by id +// from this list (via `extractCodecLookup`) to encode member values, so the target pack is the sole +// contributor of these codecs to the composed control stack. const mongoTargetDescriptorMetaBase = { kind: 'target', familyId: 'mongo', @@ -9,7 +14,12 @@ const mongoTargetDescriptorMetaBase = { version: '0.0.1', capabilities: {}, defaultNamespaceId: UNBOUND_NAMESPACE_ID, -} as const; + types: { + codecTypes: { + codecDescriptors: mongoCodecDescriptors, + }, + }, +} as const satisfies TargetPackRef<'mongo', 'mongo'>; export const mongoTargetDescriptorMeta: typeof mongoTargetDescriptorMetaBase & { readonly __codecTypes?: CodecTypes; diff --git a/packages/3-mongo-target/1-mongo-target/src/core/mongo-target-contract-serializer.ts b/packages/3-mongo-target/1-mongo-target/src/core/mongo-target-contract-serializer.ts index 68df35a836..2a3b1dc6ff 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/mongo-target-contract-serializer.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/mongo-target-contract-serializer.ts @@ -4,8 +4,10 @@ import { type MongoCollectionInput, type MongoContract, MongoStorage, + type MongoValueSetInput, } from '@prisma-next/mongo-contract'; import { blindCast } from '@prisma-next/utils/casts'; +import { ifDefined } from '@prisma-next/utils/defined'; import type { JsonObject } from '@prisma-next/utils/json'; import type { MongoTargetContract } from './mongo-target-contract'; import { MongoTargetDatabase, MongoTargetUnboundDatabase } from './mongo-target-database'; @@ -16,19 +18,33 @@ export class MongoTargetContractSerializer extends MongoContractSerializerBase { const collectionCount = Object.keys(nsData.entries.collection ?? {}).length; - if (nsId === UNBOUND_NAMESPACE_ID && collectionCount === 0) { + const valueSetCount = Object.keys(nsData.entries['valueSet'] ?? {}).length; + if (nsId === UNBOUND_NAMESPACE_ID && collectionCount === 0 && valueSetCount === 0) { return [nsId, MongoTargetUnboundDatabase.instance]; } const dbInput: { id: string; - entries?: Readonly>>>; + entries?: Readonly>>> & { + readonly collection?: Readonly>; + readonly valueSet?: Readonly>; + }; } = { id: nsData.id }; - if (nsData.entries['collection'] !== undefined) { + if ( + nsData.entries['collection'] !== undefined || + nsData.entries['valueSet'] !== undefined + ) { dbInput.entries = { collection: blindCast< Readonly>, 'collection entries validated by the mongo storage schema before hydration' - >(nsData.entries['collection']), + >(nsData.entries['collection'] ?? {}), + ...ifDefined( + 'valueSet', + blindCast< + Readonly> | undefined, + 'valueSet entries validated by the mongo storage schema before hydration' + >(nsData.entries['valueSet']), + ), }; } return [nsId, new MongoTargetDatabase(dbInput)]; @@ -49,10 +65,20 @@ export class MongoTargetContractSerializer extends MongoContractSerializerBase = {}; + for (const [vsName, vs] of Object.entries(ns.entries.valueSet ?? {})) { + valueSetOut[vsName] = blindCast< + JsonObject, + 'a value-set IR node serializes to a JSON-safe { kind, values } object' + >(JSON.parse(JSON.stringify(vs))); + } namespacesJson[nsId] = { id: ns.id, kind: 'mongo-database', - entries: { collection: collectionsOut }, + entries: { + collection: collectionsOut, + ...ifDefined('valueSet', Object.keys(valueSetOut).length > 0 ? valueSetOut : undefined), + }, }; } return blindCast< diff --git a/packages/3-mongo-target/1-mongo-target/src/core/mongo-target-database.ts b/packages/3-mongo-target/1-mongo-target/src/core/mongo-target-database.ts index 1a25468396..b73b9d58a1 100644 --- a/packages/3-mongo-target/1-mongo-target/src/core/mongo-target-database.ts +++ b/packages/3-mongo-target/1-mongo-target/src/core/mongo-target-database.ts @@ -8,13 +8,17 @@ import type { MongoCollection, MongoCollectionInput, MongoNamespaceEntries, + MongoValueSetInput, } from '@prisma-next/mongo-contract'; import { composeMongoEntityKinds } from '@prisma-next/mongo-contract/entity-kinds'; import { blindCast } from '@prisma-next/utils/casts'; export interface MongoTargetDatabaseInput { readonly id: string; - readonly entries?: Readonly>>>; + readonly entries?: Readonly>>> & { + readonly collection?: Readonly>; + readonly valueSet?: Readonly>; + }; } /** @@ -40,7 +44,7 @@ export class MongoTargetDatabase extends NamespaceBase { super(); this.id = input.id; - const rawEntries: Record>> = { + const rawEntries: Record>> = { collection: {}, ...input.entries, }; diff --git a/packages/3-mongo-target/1-mongo-target/src/exports/pack.ts b/packages/3-mongo-target/1-mongo-target/src/exports/pack.ts index 98c21079bf..0f8871877e 100644 --- a/packages/3-mongo-target/1-mongo-target/src/exports/pack.ts +++ b/packages/3-mongo-target/1-mongo-target/src/exports/pack.ts @@ -1,3 +1,4 @@ +import type { AnyCodecDescriptor } from '@prisma-next/framework-components/codec'; import { mongoTargetDescriptorMeta } from '../core/descriptor-meta'; import type { CodecTypes } from './codec-types'; @@ -9,6 +10,9 @@ const mongoTargetPack: { readonly version: '0.0.1'; readonly capabilities: Record; readonly defaultNamespaceId: '__unbound__'; + readonly types: { + readonly codecTypes: { readonly codecDescriptors: ReadonlyArray }; + }; readonly __codecTypes?: CodecTypes; } = mongoTargetDescriptorMeta; diff --git a/packages/3-mongo-target/1-mongo-target/test/descriptor-meta.test.ts b/packages/3-mongo-target/1-mongo-target/test/descriptor-meta.test.ts index 6a4e571fff..735f8dbda2 100644 --- a/packages/3-mongo-target/1-mongo-target/test/descriptor-meta.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/descriptor-meta.test.ts @@ -3,8 +3,8 @@ import { mongoTargetDescriptorMeta } from '../src/core/descriptor-meta'; import mongoTargetPack from '../src/exports/pack'; describe('mongoTargetDescriptorMeta', () => { - it('has the expected shape', () => { - expect(mongoTargetDescriptorMeta).toEqual({ + it('has the expected identity shape', () => { + expect(mongoTargetDescriptorMeta).toMatchObject({ kind: 'target', familyId: 'mongo', targetId: 'mongo', @@ -15,6 +15,14 @@ describe('mongoTargetDescriptorMeta', () => { }); }); + it('carries its target codec descriptors for build-time authoring', () => { + const codecIds = mongoTargetDescriptorMeta.types.codecTypes.codecDescriptors.map( + (d) => d.codecId, + ); + expect(codecIds).toContain('mongo/string@1'); + expect(codecIds).toContain('mongo/objectId@1'); + }); + it('declares defaultNamespaceId as __unbound__', () => { expect(mongoTargetDescriptorMeta.defaultNamespaceId).toBe('__unbound__'); }); diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.test.ts index 1a7e832c95..50e876261a 100644 --- a/packages/3-mongo-target/1-mongo-target/test/mongo-planner.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/mongo-planner.test.ts @@ -1,11 +1,14 @@ +import { coreHash, type JsonValue } from '@prisma-next/contract/types'; import type { MigrationOperationPolicy } from '@prisma-next/framework-components/control'; import { + buildMongoNamespace, MongoCollection, type MongoCollectionOptions, type MongoCollectionOptionsInput, type MongoContract, type MongoIndex, type MongoIndexInput, + MongoStorage, type MongoValidator, type MongoValidatorInput, } from '@prisma-next/mongo-contract'; @@ -1688,4 +1691,46 @@ describe('MongoMigrationPlanner', () => { expect(empty.origin).toBeNull(); }); }); + + describe('value set is non-physical', () => { + // Build the value-set-carrying storage through the real mongo-contract IR factories + // (`buildMongoNamespace` hydrates `entries.valueSet` into `MongoValueSet` nodes; `MongoStorage` + // wraps it) rather than patching a raw contract object — the namespace factory is the same + // construction path authoring uses. + function makeContractWithValueSet( + collections: Record, + valueSets: Record< + string, + { readonly kind: 'valueSet'; readonly values: readonly JsonValue[] } + >, + ): MongoContract { + const base = makeContract(collections); + const builtCollections: Record = {}; + for (const [name, data] of Object.entries(collections)) { + builtCollections[name] = makeStorageCollection(data); + } + const namespace = buildMongoNamespace({ + id: '__unbound__', + entries: { collection: builtCollections, valueSet: valueSets }, + }); + const storage = new MongoStorage({ + storageHash: coreHash('sha256:test-storage'), + namespaces: { __unbound__: namespace }, + }); + return { ...base, storage }; + } + + it('emits no migration op when the only storage delta is a value-set addition', () => { + // Origin schema matches the collection exactly; the contract adds a value + // set. The planner reads only entries.collection, so the value set produces + // no op — the validator (physical artifact) is unchanged. + const contract = makeContractWithValueSet( + { users: { indexes: [{ keys: [{ field: 'email', direction: 1 }] }] } }, + { Role: { kind: 'valueSet', values: ['admin', 'author', 'reader'] } }, + ); + const origin = irWithCollection('users', [ascIndex('email')]); + const plan = planSuccess(planner, contract, origin); + expect(plan.operations).toHaveLength(0); + }); + }); }); diff --git a/packages/3-mongo-target/1-mongo-target/test/mongo-target-contract-serializer.test.ts b/packages/3-mongo-target/1-mongo-target/test/mongo-target-contract-serializer.test.ts index 5a9abcd9d2..e7efb0a393 100644 --- a/packages/3-mongo-target/1-mongo-target/test/mongo-target-contract-serializer.test.ts +++ b/packages/3-mongo-target/1-mongo-target/test/mongo-target-contract-serializer.test.ts @@ -7,6 +7,7 @@ import { MongoIndex, MongoStorage, MongoValidator, + MongoValueSet, } from '@prisma-next/mongo-contract'; import { applicationDomainOf } from '@prisma-next/test-utils'; import { describe, expect, it } from 'vitest'; @@ -276,4 +277,47 @@ describe('MongoTargetContractSerializer', () => { expect(roundtrippedNs!.entries.collection?.['items']).toBeInstanceOf(MongoCollection); }); }); + + describe('entries.valueSet round-trip', () => { + function makeValueSetContractJson() { + const base = makeValidContractJson(); + const ns = base.storage.namespaces[UNBOUND_NAMESPACE_ID] as { + entries: Record; + }; + ns.entries['valueSet'] = { + Role: { kind: 'valueSet', values: ['admin', 'author', 'reader'] }, + }; + return base; + } + + it('hydrates entries.valueSet into MongoValueSet IR instances (deserialize)', () => { + const serializer = new MongoTargetContractSerializer(); + const contract = serializer.deserializeContract(makeValueSetContractJson()); + const ns = contract.storage.namespaces[UNBOUND_NAMESPACE_ID]; + const roleVs = ns!.entries.valueSet?.['Role']; + expect(roleVs).toBeInstanceOf(MongoValueSet); + expect(roleVs?.values).toEqual(['admin', 'author', 'reader']); + }); + + it('serializes entries.valueSet back to JSON with values intact (serialize)', () => { + const serializer = new MongoTargetContractSerializer(); + const contract = serializer.deserializeContract(makeValueSetContractJson()); + const out = JSON.parse(JSON.stringify(serializer.serializeContract(contract))); + const entries = out.storage.namespaces[UNBOUND_NAMESPACE_ID].entries; + expect(entries.valueSet).toEqual({ + Role: { kind: 'valueSet', values: ['admin', 'author', 'reader'] }, + }); + }); + + it('survives a full serialize → deserialize cycle with the value set intact (both directions)', () => { + const serializer = new MongoTargetContractSerializer(); + const contract = serializer.deserializeContract(makeValueSetContractJson()); + const reparsed = JSON.parse(JSON.stringify(serializer.serializeContract(contract))); + const roundtripped = serializer.deserializeContract(reparsed); + const ns = roundtripped.storage.namespaces[UNBOUND_NAMESPACE_ID]; + const roleVs = ns!.entries.valueSet?.['Role']; + expect(roleVs).toBeInstanceOf(MongoValueSet); + expect(roleVs?.values).toEqual(['admin', 'author', 'reader']); + }); + }); }); diff --git a/packages/3-mongo-target/2-mongo-adapter/package.json b/packages/3-mongo-target/2-mongo-adapter/package.json index bc27dda36a..237d215919 100644 --- a/packages/3-mongo-target/2-mongo-adapter/package.json +++ b/packages/3-mongo-target/2-mongo-adapter/package.json @@ -66,6 +66,7 @@ "exports": { ".": "./dist/index.mjs", "./codec-types": "./dist/codec-types.mjs", + "./codecs": "./dist/codecs.mjs", "./control": "./dist/control.mjs", "./runtime": "./dist/runtime.mjs", "./package.json": "./package.json" diff --git a/packages/3-mongo-target/2-mongo-adapter/src/exports/codecs.ts b/packages/3-mongo-target/2-mongo-adapter/src/exports/codecs.ts new file mode 100644 index 0000000000..1865f5bb88 --- /dev/null +++ b/packages/3-mongo-target/2-mongo-adapter/src/exports/codecs.ts @@ -0,0 +1 @@ +export { mongoCodecDescriptors, mongoDescriptorById } from '../core/codecs'; diff --git a/packages/3-mongo-target/2-mongo-adapter/src/exports/control.ts b/packages/3-mongo-target/2-mongo-adapter/src/exports/control.ts index 6106046f20..f4fb76964f 100644 --- a/packages/3-mongo-target/2-mongo-adapter/src/exports/control.ts +++ b/packages/3-mongo-target/2-mongo-adapter/src/exports/control.ts @@ -14,7 +14,6 @@ export { export { createMongoAdapter } from '../mongo-adapter'; export type { MongoControlDriverInstance }; -import { mongoCodecDescriptors } from '../core/codecs'; import { MongoControlAdapterImpl } from '../core/mongo-control-adapter'; export const mongoAdapterDescriptor: MongoControlAdapterDescriptor<'mongo'> = { @@ -33,7 +32,6 @@ export const mongoAdapterDescriptor: MongoControlAdapterDescriptor<'mongo'> = { ]), types: { codecTypes: { - codecDescriptors: mongoCodecDescriptors, import: { package: '@prisma-next/adapter-mongo/codec-types', named: 'CodecTypes', diff --git a/packages/3-mongo-target/2-mongo-adapter/src/exports/runtime.ts b/packages/3-mongo-target/2-mongo-adapter/src/exports/runtime.ts index a29069d46c..35ab7f4b8e 100644 --- a/packages/3-mongo-target/2-mongo-adapter/src/exports/runtime.ts +++ b/packages/3-mongo-target/2-mongo-adapter/src/exports/runtime.ts @@ -5,7 +5,7 @@ import type { } from '@prisma-next/framework-components/execution'; import type { MongoCodecRegistry } from '@prisma-next/mongo-codec'; import type { MongoAdapter } from '@prisma-next/mongo-lowering'; -import { buildStandardCodecRegistry, mongoCodecDescriptors } from '../core/codecs'; +import { buildStandardCodecRegistry } from '../core/codecs'; import { createMongoAdapter } from '../mongo-adapter'; /** @@ -29,11 +29,6 @@ const mongoRuntimeAdapterDescriptor: RuntimeAdapterDescriptor< familyId: 'mongo', targetId: 'mongo', version: '0.0.1', - types: { - codecTypes: { - codecDescriptors: mongoCodecDescriptors, - }, - }, codecs: buildStandardCodecRegistry, create(_stack: ExecutionStack<'mongo', 'mongo'>): MongoRuntimeAdapterInstance { const adapter = createMongoAdapter(); diff --git a/packages/3-mongo-target/2-mongo-adapter/tsdown.config.ts b/packages/3-mongo-target/2-mongo-adapter/tsdown.config.ts index 58963b3361..83626694c0 100644 --- a/packages/3-mongo-target/2-mongo-adapter/tsdown.config.ts +++ b/packages/3-mongo-target/2-mongo-adapter/tsdown.config.ts @@ -5,6 +5,7 @@ export default defineConfig({ index: 'src/exports/index.ts', control: 'src/exports/control.ts', runtime: 'src/exports/runtime.ts', + codecs: 'src/exports/codecs.ts', 'codec-types': 'src/exports/codec-types.ts', }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7ef5ed6c6e..232e8a5e85 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2229,6 +2229,9 @@ importers: '@prisma-next/contract-authoring': specifier: workspace:0.14.0 version: link:../../../1-framework/2-authoring/contract + '@prisma-next/errors': + specifier: workspace:0.14.0 + version: link:../../../1-framework/1-core/errors '@prisma-next/framework-components': specifier: workspace:0.14.0 version: link:../../../1-framework/1-core/framework-components @@ -2278,6 +2281,9 @@ importers: '@prisma-next/contract-authoring': specifier: workspace:0.14.0 version: link:../../../1-framework/2-authoring/contract + '@prisma-next/errors': + specifier: workspace:0.14.0 + version: link:../../../1-framework/1-core/errors '@prisma-next/framework-components': specifier: workspace:0.14.0 version: link:../../../1-framework/1-core/framework-components @@ -2324,6 +2330,9 @@ importers: '@prisma-next/mongo-contract': specifier: workspace:0.14.0 version: link:../../1-foundation/mongo-contract + '@prisma-next/utils': + specifier: workspace:0.14.0 + version: link:../../../1-framework/0-foundation/utils devDependencies: '@prisma-next/test-utils': specifier: workspace:0.14.0 diff --git a/projects/refactor-enum-typing-via-codec/mongo-value-set-design.md b/projects/refactor-enum-typing-via-codec/mongo-value-set-design.md new file mode 100644 index 0000000000..87e8a54f21 --- /dev/null +++ b/projects/refactor-enum-typing-via-codec/mongo-value-set-design.md @@ -0,0 +1,182 @@ +# Mongo value-set typing — design (TML-2953, Level A) + +> Exhaustive design for bringing Mongo's enum typing onto the storage value set, the +> sibling of the shipped SQL work ([TML-2952](https://linear.app/prisma-company/issue/TML-2952)). +> Companion to the shared model spec [`spec.md`](spec.md) and the slice spec +> [`mongo-value-set-spec.md`](mongo-value-set-spec.md). The deferred architectural upgrade +> (explicit document projection / storage-only query builder) is +> [TML-2961](https://linear.app/prisma-company/issue/TML-2961). + +## The goal (the universal principle) + +**If a value set is present for a field/column, that determines the field's type: the union +of literal types, each literal produced by applying the codec to the corresponding value. +Absent a value set, the field gets the plain codec type.** + +This is family-agnostic and enum-agnostic. A value set is a generic restriction ("the +permitted values here are x, y, z"). A domain enum is *one* producer of a value set; a +native Postgres enum (a target-contributed storage entity) is another. The typing mechanism +knows only value sets — never "enum". This work brings Mongo onto that mechanism, matching +SQL, and removes the last enum-specific typing source in the Mongo family (the interim +`domain.enum` resolver added by TML-2952). + +## The two typing paths (precise mechanics) + +A field that carries a value set has its TypeScript type produced on two independent paths, +which must agree: + +- **Emit path** (`contract.d.ts`, the `db.context`/validated-contract consumer). At + `contract emit`, working from the **authoring source** (not by reading back + `contract.json`), the emitter holds the codec-**encoded** values from the storage value + set. For each value it **decodes through the codec** (`decodeJson`) and **renders the + decoded value as a TS literal** (`renderValueLiteral`). The union of those literals is the + field's emitted type. This is the path that *lost* the literal types to the encoded form + and rebuilds them via the codec. +- **No-emit path** (`typeof contract`). Handed the authored literal values **directly** — + the author wrote them, `const` generics preserved them on the enum handle, and the path + simply propagates the union. It never serialized them, so there is nothing to reconstruct + and no codec call is needed. It is the *easier* path. + +Both land on the same value union. Only the **emit** path changes in this work (its source +moves from `domain.enum` to the storage value set); the no-emit path is already correct and +is untouched — the same posture SQL's handle-based no-emit has. + +## The invariant — this is uniformity, not a `storageHash` correctness fix + +`storageHash = sha256(canonicalize({ schemaVersion, targetFamily, target, storage }))` +(ADR 004) covers the entire `storage` section. The Mongo collection validator — a +`MongoValidator` whose `jsonSchema` carries the enum values **inline** (verified in +`examples/mongo-demo/src/contract.json`: `"enum": ["admin","author","reader"]` sits inside +`storage.namespaces.__unbound__.entries.collection..validator.jsonSchema`) — is in that +section. So an enum-value change already flows into `storageHash` today, and Mongo already +satisfies the migration-planner isolation/uniqueness invariant. **This work is uniformity + +value-set-driven typing, not a `storageHash` correctness fix.** (The Linear ticket's "for +uniformity, not correctness" framing is correct; an intermediate reading that it was a +correctness fix was reconciled by confirming the validator-in-storage.) + +## Design (Level A) + +### 1. A value-set entity in Mongo storage — identical to SQL + +Add a value-set entity to the Mongo storage block, the same shape as SQL's `StorageValueSet`: + +```ts +{ kind: 'valueSet', values: readonly JsonValue[] } // ordered, codec-encoded; no codecId +``` + +No `codecId` — the codec lives on the field/column that references the value set. Placement +mirrors SQL's `SqlNamespace.entries.valueSet`: the value set lives at +`contract.storage.namespaces[].entries.valueSet[]`. Concretely this means the +Mongo storage namespace's `entries` gains a `valueSet` slot alongside `collection`. New IR +(a `MongoValueSet` node, or a shared value-set node if one can be lifted), its +serializer/hydration walker entry, its arktype schema, and round-trip coverage. + +### 2. Authoring populates the value set + +The Mongo authoring surfaces build the storage value set for each authored enum, mirroring +SQL's `build-contract` (which builds `domainEnumsByNs` **and** `storageValueSetsByNs` in one +loop, `values: handle.values.map(v => encodeViaCodec(v, codecId, codecLookup))`): + +- **PSL interpreter** (`contract-psl/src/interpreter.ts`): today it stamps only the *domain* + `valueSet` ref on the field (lines ~839–851). It must also emit the storage value set into + the collection's namespace `entries.valueSet`, keyed by enum name, values = the enum's + codec-encoded member values. +- **TS builder** (`contract-ts/src/contract-builder.ts`): today it stamps the domain ref from + `__enumHandle` (line ~1400). It must also build the storage value set from the enum handle + (`handle.values` + `handle.codecId`), same as SQL. +- **Namespace assembly** (`mongo-contract`'s `build-mongo-namespace.ts`): thread the + per-namespace value sets into the storage namespace's `entries.valueSet`, mirroring SQL's + namespace assembly. + +The field keeps its existing **domain** `valueSet` ref (`plane: 'domain', entityKind: 'enum', +entityName: `) — unchanged. The storage value set is named identically (the enum +name), which is how the emitter finds it (§4). + +### 3. Re-source typing and the validator from the value set + +- **Emit typing** — `mongoEmission.resolveFieldValueSet` (`3-tooling/emitter/src/index.ts`, + the interim resolver): source the permitted `encodedValues` from the **storage value set** + (looked up by the field ref's `entityName`), and `codecId` from the **field**. Delete the + interim `domain.enum` read and the `INTERIM (TML-2953)` comment. The framework then renders + `FieldOutputTypes`/`FieldInputTypes` through `renderValueSetType` → `renderValueLiteralFor` + exactly as SQL does — no Mongo-specific typing code remains. +- **Validator** — `contract-psl/src/derive-json-schema.ts`: the validator's `enum` keyword is + today sourced from `enums?.[field.valueSet.entityName]?.members.map(m => m.value)` (line + ~22–24). Re-source it from the storage value set's `values`. The rendered validator is + **byte-identical**: the value set's `values` are the `encodeJson`'d member values, which + equal the current `m.value`. + +After this, **no Mongo code reads `domain.enum` to produce a type or the validator's +enforcement.** The only remaining `domain.enum` reader is `db.enums` (runtime dictionary), +which is the intended keep. + +### 4. Field → value-set link: by name (Level A) + +The emitter reads both planes, so it resolves the storage value set by the **name** carried +on the field's (domain) `valueSet` ref (`entityName`). **No explicit per-field storage ref is +added.** This is legitimate because the emitter is not storage-isolated. The query builder +(which *is* storage-only in SQL) does **not** need this link in Mongo, because Mongo's query +builder types via `FieldOutputTypes` (see §5), not a storage-side map. + +The explicit, first-class per-field storage coordinate — and a storage-only Mongo query +builder — is [TML-2961](https://linear.app/prisma-company/issue/TML-2961). It is deferred +because nothing drives it for Mongo: the feature that drives storage-only value-set typing is +native Postgres enums, which are Postgres-only and never touch Mongo. + +### 5. What is unchanged + +- **Query builder / ORM.** Mongo types documents via `MongoTypeMaps` — i.e. off the framework-emitted `FieldOutputTypes`, + not a storage-side `StorageColumnTypes` like SQL. `FieldOutputTypes` is now sourced from + the value set (§3), so enum fields narrow correctly with no query-builder change. +- **No-emit path** (`BuilderEnumValueUnion` from `__enumHandle`). Handed the authored values; + already the value union; untouched. +- **`db.enums`.** Still built from `domain.enum` members. Runtime-only; unchanged. +- **The materialized validator.** Still in storage; the migration planner/verify read it as + the physical artifact. + +### 6. Migration / verify + +- The validator is **byte-identical** (same enum values, re-sourced), so there is **no new + physical migration op** for existing enums. Adding the value-set entity changes + `storageHash` (a new storage entity), so the Mongo example migration snapshots regenerate + to carry it — a contract-shape regeneration, not a physical change. +- `db verify` compares the expected validator (now derived from the value set) against the + live validator — unchanged values, so it passes. +- **Implementation obligation:** confirm the Mongo schema diff/planner treats the value-set + entity as **non-physical** — it must emit **no** migration op for the value set alone (the + validator is the physical artifact). A future enum-value change correctly changes the value + set *and* the validator and *does* produce a `collMod` — that is the desired, non-spurious + behavior. + +## Consumers — who reads what, after this work + +| Consumer | Reads for enum typing/enforcement | +| --- | --- | +| Emit typing (`FieldOutputTypes`) | storage **value set** (by name) + field codec → `renderValueLiteral` | +| No-emit (`typeof contract`) | the authored **enum handle** (value union, direct) | +| Validator deriver | storage **value set** → `$jsonSchema.enum` | +| Migration planner / verify | the materialized **validator** (physical artifact) | +| `db.enums` (runtime) | `domain.enum` members (the intended keep) | + +## Not byte-identical (unlike TML-2952) + +Adding a storage entity means Mongo enum contracts (`examples/mongo-demo`, +`examples/retail-store`) and their migration snapshots regenerate to carry the value set. +Accepted (operator: "I don't care how many contracts change"). + +## Alternatives considered + +- **No storage value set; resolve everything by name off `domain.enum`.** Rejected — the + value set must be *in storage* for uniformity with SQL and as the single value-set-driven + typing source; "by name" describes only the field→value-set link, not the value set's + existence. +- **Explicit per-field storage ref + storage-only Mongo query builder (Level B).** Deferred — + real work for uniformity with no feature riding on it (native enums, the driver, are + Postgres-only). The awkward middle. +- **Explicit per-collection document projection (Level C).** [TML-2961] — the architectural + end-state (collection ≈ table, projected fields ≈ columns), motivated by Mongo's + nested-document handling, not enums. Its own project. +- **Rework the no-emit path to be value-set-driven.** Rejected — the no-emit path is *handed* + the authored literals and is already correct; making it value-set-driven is unnecessary and + would require type-level codec application. Kept handle-based, matching SQL. diff --git a/projects/refactor-enum-typing-via-codec/mongo-value-set-plan.md b/projects/refactor-enum-typing-via-codec/mongo-value-set-plan.md new file mode 100644 index 0000000000..5a1b03b5f4 --- /dev/null +++ b/projects/refactor-enum-typing-via-codec/mongo-value-set-plan.md @@ -0,0 +1,87 @@ +# Plan: Mongo enum fields type through a storage value set (TML-2953, Level A) + +**Spec:** [`mongo-value-set-spec.md`](mongo-value-set-spec.md) · **Design:** +[`mongo-value-set-design.md`](mongo-value-set-design.md) · **Linear:** +[TML-2953](https://linear.app/prisma-company/issue/TML-2953) · **Follow-up:** +[TML-2961](https://linear.app/prisma-company/issue/TML-2961). + +Branches off `main` (TML-2952 / PR #896 is merged, so `renderValueLiteral` / +`renderValueSetType` / `resolveFieldValueSet` are available). Test-first throughout. + +## Slice DoD + +Spec acceptance A1–A8: Mongo storage carries a value set (SQL shape); the validator's `enum` +and the emit typing are sourced from it; enum fields type as the value union on emit **and** +`typeof contract`, SQL-shaped; no Mongo code reads `domain.enum` for typing/enforcement (only +`db.enums` does, at runtime); `db verify` / migration behavior unchanged; the interim comment +is gone; no new casts; full gate set green. + +## Dispatches (sequential) + +### D1 — Mongo storage value-set IR (substrate, dark) +- **Outcome:** Mongo storage can carry a value-set entity of the same shape as SQL + (`{ kind: 'valueSet', values: readonly JsonValue[] }`) at + `storage.namespaces[].entries.valueSet[]`. It round-trips through the Mongo + serializer/hydration and validates. Nothing reads it yet. +- **Builds on:** nothing. +- **Hands to:** D2 (authoring populates it). +- **Focus:** the value-set IR node in `mongo-contract` (a `MongoValueSet` or a lifted shared + node), the `valueSet` slot on the namespace `entries`, the serializer hydration walker + entry, the arktype `contract-schema.ts` entry, and `ir/build-mongo-namespace.ts` threading. + Round-trip + validation tests. + +### D2 — Authoring populates the value set; contracts/migrations reconcile +- **Outcome:** Authoring a Mongo enum emits the storage value set (values = codec-encoded + member values) into `entries.valueSet`, alongside the domain enum; the field keeps its + domain `valueSet` ref. The Mongo example contracts (`mongo-demo`, `retail-store`) and any + Mongo migration `*-contract` snapshots regenerate to carry it, and **the planner emits no + migration op for the value-set entity** (it is non-physical — the validator is the physical + artifact, unchanged). `fixtures:check` is green (showing only the intended value-set + regeneration). Typing/validator still read `domain.enum` at this point (re-sourced in D3). +- **Builds on:** D1. +- **Hands to:** D3 (typing + validator can now source from the value set). +- **Focus:** `contract-psl/src/interpreter.ts` and `contract-ts/src/contract-builder.ts` build + + place the value set, mirroring SQL's `build-contract` (`storageValueSetsByNs`, + `values: handle.values.map(encodeViaCodec)`); regenerate the example contracts + + migration snapshots; **verify the Mongo schema diff/planner treats the value set as + non-physical** (the open question in the spec — if it emits a spurious op, teach the diff + the value set is non-physical, in scope). Tests: emit a Mongo enum contract → storage + carries the value set with the right encoded values. + +### D3 — Switchover: source typing + validator from the value set; delete the interim +- **Outcome:** `mongoEmission.resolveFieldValueSet` sources `encodedValues` from the storage + value set (by the field ref's `entityName`) and `codecId` from the field; the + `derive-json-schema` validator's `enum` is sourced from the value set. The interim + `domain.enum` resolver and its `INTERIM (TML-2953)` comment are **deleted**. The emitted + validator and `FieldOutputTypes` are **byte-identical** (same values, different source), + so no contract change. No Mongo code reads `domain.enum` for typing/enforcement. +- **Builds on:** D1 + D2. +- **Hands to:** D4 (acceptance). +- **Focus:** `3-tooling/emitter/src/index.ts` (`resolveFieldValueSet`) and + `contract-psl/src/derive-json-schema.ts`. Tests: validator byte-identical; `FieldOutputTypes` + enum field narrows via the value set; grep confirms no `domain.enum` typing/validator read + remains (only `db.enums`). + +### D4 — Acceptance proof + full gate set +- **Outcome:** The slice DoD is demonstrably met. Type tests prove a Mongo enum field types as + the value union on the emitted **and** `typeof contract` paths, SQL-shaped (A3); a + non-identity test codec proves the emit path types the codec **output**, not the encoded + value (A5); a grep guard asserts no Mongo type/validator path reads `domain.enum` (A4); the + interim comment is gone (A7); `db verify` / migration behavior unchanged (A6). Upgrade + instructions recorded for the touched `examples/` (incidental) and any + `packages/3-extensions/`. Full gate set green (A8): build, typecheck, `lint:deps`, + `lint:casts`, `fixtures:check`, `check:upgrade-coverage`, all three test suites. +- **Builds on:** D2 + D3. +- **Hands to:** PR-open. +- **Focus:** the cross-cutting type tests + grep guard; `record-upgrade-instructions`; the + full CI-equivalent gate run. + +## Sequencing notes + +- **D2 is where the contract shape changes** (not byte-identical) and where the migration/ + planner behavior is exercised — the spec's one risk item lives here. D3 is byte-identical + (re-sources the same values). +- The **no-emit path is not touched** in any dispatch — it is handed the authored values and + is already the value union (§ design). D3 is emit-side + validator only. +- The **explicit per-field storage ref / storage-only query builder** is out of scope + (TML-2961); the field→value-set link stays by-name in the emitter. diff --git a/projects/refactor-enum-typing-via-codec/mongo-value-set-spec.md b/projects/refactor-enum-typing-via-codec/mongo-value-set-spec.md new file mode 100644 index 0000000000..c0156a03e7 --- /dev/null +++ b/projects/refactor-enum-typing-via-codec/mongo-value-set-spec.md @@ -0,0 +1,113 @@ +# Spec: Mongo enum fields type through a storage value set (TML-2953, Level A) + +**Linear:** [TML-2953](https://linear.app/prisma-company/issue/TML-2953) · sibling of shipped +[TML-2952](https://linear.app/prisma-company/issue/TML-2952) · follow-up +[TML-2961](https://linear.app/prisma-company/issue/TML-2961) · design: +[`mongo-value-set-design.md`](mongo-value-set-design.md) · model: [`spec.md`](spec.md). + +## Summary + +Bring Mongo's enum typing onto the same mechanism SQL uses: **a field carrying a value set +gets its type from that value set — the union of the codec-rendered literals of its values — +not the plain codec type.** Add the value-set entity to Mongo storage (same shape as SQL), +source the emit typing and the `$jsonSchema` validator from it instead of `domain.enum`, and +delete the interim `domain.enum` resolver. The domain `enum` becomes runtime-only for Mongo +too (it still powers `db.enums`). Uniformity + value-set-driven typing, **not** a +`storageHash` correctness fix (the validator is already materialized in storage, so +`storageHash` already covers enum values). **Not byte-identical** — Mongo enum contracts and +their migration snapshots regenerate to carry the value set. The Mongo query builder and the +no-emit (`typeof contract`) path are **unchanged**. + +## Requirements + +- **R1 — Value set in Mongo storage.** Mongo storage carries a value-set entity of the same + shape as SQL's (`{ kind: 'valueSet', values: readonly JsonValue[] }`, codec-encoded, no + `codecId`), at `storage.namespaces[].entries.valueSet[]`. It round-trips through + the Mongo serializer/hydration and passes validation. +- **R2 — Authoring populates it.** For each authored enum, the Mongo PSL interpreter and the + Mongo TS builder emit the storage value set (values = the enum's codec-encoded member + values) alongside the domain enum, mirroring SQL's `build-contract`. The field keeps its + existing **domain** `valueSet` ref. +- **R3 — Emit typing from the value set.** `mongoEmission.resolveFieldValueSet` sources the + permitted values from the storage value set (by the field ref's `entityName`) and the + `codecId` from the field. `FieldOutputTypes`/`FieldInputTypes` for an enum field then + narrow to the value union via the shared `renderValueSetType` → `renderValueLiteralFor` + path. No Mongo-specific enum-typing code remains; the interim `domain.enum` resolver and + its `INTERIM` comment are deleted. +- **R4 — Validator from the value set.** `derive-json-schema` sources the collection + validator's `enum` keyword from the storage value set's `values`, not from + `domain.enum` members. The rendered validator is byte-identical (the value set's values + equal the encoded member values). +- **R5 — Typed field, both paths.** A Mongo enum document field types as the value union on + the **emitted** `contract.d.ts` path **and** the no-emit `typeof contract` path, identical + in shape to SQL. (The no-emit path is unchanged — it is handed the authored values.) +- **R6 — Domain enum is not a Mongo typing/enforcement input.** No Mongo code reads + `domain.enum` to produce a type or the validator's `enum`. The only remaining `domain.enum` + reader is `db.enums` (runtime dictionary) — the intended keep. +- **R7 — Migration/verify unchanged.** The validator is byte-identical, so no new physical + migration op is produced for existing enums; `db verify` passes against the live validator. + The Mongo schema diff/planner treats the value-set entity as **non-physical** (emits no + migration op for the value set alone). + +## Changes by surface + +1. **Mongo storage IR** — `packages/2-mongo-family/1-foundation/mongo-contract`: add the + value-set entity (a `MongoValueSet` IR node or a lifted shared node) and a `valueSet` slot + on the namespace `entries`; wire the serializer hydration walker and the arktype + contract-schema (`contract-schema.ts`) to construct/validate it; namespace assembly + (`ir/build-mongo-namespace.ts`) threads per-namespace value sets into `entries.valueSet`. +2. **Mongo authoring** — populate the value set: + - `2-authoring/contract-psl/src/interpreter.ts` — build + place the storage value set for + each PSL enum (it already stamps the domain ref). + - `2-authoring/contract-ts/src/contract-builder.ts` — build + place the storage value set + from each enum handle (it already stamps the domain ref from `__enumHandle`). +3. **Mongo emit typing** — `3-tooling/emitter/src/index.ts`: rework + `resolveFieldValueSet` to source `encodedValues` from the storage value set (by name) and + `codecId` from the field; delete the interim `domain.enum` read and the `INTERIM` comment. +4. **Validator deriver** — `2-authoring/contract-psl/src/derive-json-schema.ts`: source the + `enum` keyword from the storage value set instead of `domain.enum`. +5. **Fixtures / examples** — regenerate `examples/mongo-demo` and `examples/retail-store` + contracts (`contract.json`/`contract.d.ts`) and any Mongo migration `*-contract` snapshots + to carry the value set. Record the 0.14→0.15 upgrade declaration(s) for the touched + `examples/` (incidental) and `packages/3-extensions/` (if touched) per + `record-upgrade-instructions`. + +## Acceptance criteria + +- **A1 — Storage shape.** Mongo storage carries a value-set entity of the same shape as SQL, + at `entries.valueSet`; it round-trips and validates. (R1) +- **A2 — Validator sourced from the value set.** The `$jsonSchema` `enum` is generated from + the value set; the rendered validator is byte-identical to before. (R4) +- **A3 — Typed field, both paths, SQL-shaped.** A Mongo enum field types as the value union + on the emitted path **and** `typeof contract`, matching SQL's shape. Proven by type tests + on both. (R5) +- **A4 — Domain enum not a typing/enforcement input.** The interim resolver and the + `derive-json-schema` `domain.enum` read are gone; grep confirms no Mongo type/validator + path reads `domain.enum`; `db.enums` still does (runtime). (R3, R6) +- **A5 — Non-identity codec correctness (emit).** A test value set over a non-identity codec + types (on the emit path) as the codec **output** literals, not the raw encoded values — + reusing the shared mechanism. (Confirms the value-set+codec path, not coincidence.) +- **A6 — Migration/verify unchanged.** No new physical migration op for the existing Mongo + enums; `db verify` passes; the planner emits nothing for the value-set entity alone. (R7) +- **A7 — Interim comment deleted.** The `INTERIM (TML-2953)` comment in the Mongo emitter is + removed. (Ticket acceptance.) +- **A8 — Gates.** No new bare casts; `build`, `typecheck`, `lint:deps`, `lint:casts`, + `fixtures:check` (expected to show the intended Mongo contract regeneration, nothing else), + and the full test suites pass. + +## Non-goals + +- The explicit per-field storage value-set ref, a storage-only Mongo query builder, and the + explicit per-collection document projection — all [TML-2961](https://linear.app/prisma-company/issue/TML-2961). +- Reworking the no-emit (`typeof contract`) path — it is handed the authored values and is + already correct (kept handle-based, matching SQL). +- Native Postgres enums — Postgres-only, do not touch Mongo; pursued separately. +- Any change to `db.enums` runtime behavior or the authoring surface (`enumType` / PSL enum). + +## Open questions + +- **Planner treatment of the value-set entity (R7/A6).** Confirm at implementation that the + Mongo schema diff/planner emits no migration op for the value-set entity itself (the + validator is the physical artifact). If it does emit a spurious op, that is an in-scope fix + (teach the diff that the value set is non-physical). This is the one place the slice could + grow teeth. diff --git a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md index b259d909c2..8e71e1e888 100644 --- a/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md +++ b/skills/extension-author/prisma-next-extension-upgrade/upgrades/0.14-to-0.15/instructions.md @@ -54,6 +54,24 @@ changes: - "renderValueTypeFor" - "renderOutputType" anyMatch: true + - id: mongo-derive-json-schema-value-sets-param + summary: | + `deriveJsonSchema` / `derivePolymorphicJsonSchema` (from `@prisma-next/mongo-contract-psl`) now + source a value-set field's `$jsonSchema` `enum` keyword from a value-set map, not the domain + enum. Their fourth argument changed from a domain-enum map + (`Record`, read as `members.map(m => m.value)`) to a value-set map + (`FieldValueSets` = `Record`, keyed by the field's + `valueSet` `entityName`). If your extension calls either function directly, pass the storage + value sets (`contract.storage.namespaces[].entries.valueSet`) instead of `domain.enum`; the + values are identical for enums, so the rendered validator is unchanged. Most extensions author + Mongo contracts through `mongoContract(...)` / `defineContract(...)`, which call these + internally — those need no change. + detection: + glob: "**/*.{ts,mts,cts}" + contains: + - "deriveJsonSchema" + - "derivePolymorphicJsonSchema" + anyMatch: true --- + +