From 22d23b72c744c7e235f1efc787ea2a1d60d77fd1 Mon Sep 17 00:00:00 2001 From: Saad Hassan Date: Wed, 12 Aug 2026 22:26:34 +0300 Subject: [PATCH 1/2] fix(contract): preserve empty model fields Signed-off-by: Saad Hassan --- .../contract/src/canonicalization.ts | 29 +++++++++++---- .../test/interpreter.polymorphism.test.ts | 36 +++++++++++++++++++ 2 files changed, 58 insertions(+), 7 deletions(-) diff --git a/packages/1-framework/0-foundation/contract/src/canonicalization.ts b/packages/1-framework/0-foundation/contract/src/canonicalization.ts index 16d95e028f38..afff97a4fa7c 100644 --- a/packages/1-framework/0-foundation/contract/src/canonicalization.ts +++ b/packages/1-framework/0-foundation/contract/src/canonicalization.ts @@ -1,4 +1,5 @@ import { isArrayEqual } from '@internal/utils/array-equal'; +import { blindCast } from '@internal/utils/casts'; import { ifDefined } from '@internal/utils/defined'; import type { JsonObject } from '@internal/utils/json'; import { matchesPathPattern, type PathPattern } from './canonicalization-path-match'; @@ -48,6 +49,14 @@ const DOMAIN_MODEL_RELATIONS_PATTERN = [ '*', 'relations', ] as const satisfies PathPattern; +const DOMAIN_MODEL_FIELDS_PATTERN = [ + 'domain', + 'namespaces', + '*', + 'models', + '*', + 'fields', +] as const satisfies PathPattern; const DOMAIN_MODEL_STORAGE_PATTERN = [ 'domain', 'namespaces', @@ -142,6 +151,7 @@ function omitDefaults( 'defaults', ]); const isExtensionNamespace = currentPath.length === 2 && currentPath[0] === 'extensions'; + const isModelFields = matchesPathPattern(currentPath, DOMAIN_MODEL_FIELDS_PATTERN); const isModelRelations = matchesPathPattern(currentPath, DOMAIN_MODEL_RELATIONS_PATTERN); const isModelStorage = matchesPathPattern(currentPath, DOMAIN_MODEL_STORAGE_PATTERN); @@ -161,6 +171,7 @@ function omitDefaults( !isRequiredMeta && !isRequiredExecutionDefaults && !isExtensionNamespace && + !isModelFields && !isModelRelations && !isModelStorage && !isNullableField && @@ -186,9 +197,10 @@ function sortObjectKeys(obj: unknown): unknown { } const sorted: Record = {}; - const keys = Object.keys(obj).sort(); + const record = Object.fromEntries(Object.entries(obj)); + const keys = Object.keys(record).sort(); for (const key of keys) { - sorted[key] = sortObjectKeys((obj as Record)[key]); + sorted[key] = sortObjectKeys(record[key]); } return sorted; @@ -266,14 +278,17 @@ export function canonicalizeContractToObject( ...ifDefined('defaultControlPolicy', serialized['defaultControlPolicy']), meta: serialized['meta'], }; - const withDefaultsOmitted = omitDefaults(normalized, [], options.shouldPreserveEmpty) as Record< - string, - unknown - >; + const withDefaultsOmitted = blindCast< + Record, + 'omitDefaults preserves the record shape of its normalized contract input' + >(omitDefaults(normalized, [], options.shouldPreserveEmpty)); const withSortedStorage = options.sortStorage ? { ...withDefaultsOmitted, storage: options.sortStorage(withDefaultsOmitted['storage']) } : withDefaultsOmitted; - const withSortedKeys = sortObjectKeys(withSortedStorage) as Record; + const withSortedKeys = blindCast< + Record, + 'sortObjectKeys preserves the record shape of its contract input' + >(sortObjectKeys(withSortedStorage)); return orderTopLevel(withSortedKeys); } diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts index c3c91050f7ec..d4725e2cd9a2 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts @@ -1,7 +1,10 @@ +import { canonicalizeContractToObject } from '@internal/contract/hashing'; import type { Contract } from '@internal/contract/types'; import { crossRef } from '@internal/contract/types'; import type { CodecLookup } from '@internal/framework-components/codec'; import { UNBOUND_NAMESPACE_ID } from '@internal/framework-components/ir'; +import { MongoContractSchema } from '@internal/mongo-contract'; +import { mongoContractCanonicalizationHooks } from '@internal/mongo-contract/canonicalization-hooks'; function modelsOf(ir: Contract): Record { return ir.domain.namespaces[UNBOUND_NAMESPACE_ID]!.models; @@ -10,6 +13,7 @@ function modelsOf(ir: Contract): Record { import { buildSymbolTable, type SymbolTable } from '@internal/psl-parser'; import type { SourceFile } from '@internal/psl-parser/syntax'; import { parse } from '@internal/psl-parser/syntax'; +import type { JsonObject } from '@internal/utils/json'; import { describe, expect, it } from 'vitest'; import { interpretPslDocumentToMongoContract } from '../src/interpreter'; @@ -130,6 +134,38 @@ describe('interpretPslDocumentToMongoContract — polymorphism', () => { expect(modelsOf(ir)['Bug']).toMatchObject({ base: crossRef('Task') }); }); + it('preserves empty fields on a field-less variant through canonicalization', () => { + const ir = interpretOk(` + model Post { + id ObjectId @id @map("_id") + title String + kind String + + @@discriminator(kind) + @@map("posts") + } + + model Note { + @@base(Post, "note") + } + `); + + expect(modelsOf(ir)['Note']).toMatchObject({ fields: {} }); + + const canonical = canonicalizeContractToObject(ir, { + serializeContract: (contract) => JSON.parse(JSON.stringify(contract)) as JsonObject, + shouldPreserveEmpty: mongoContractCanonicalizationHooks.shouldPreserveEmpty, + }); + const note = ( + canonical['domain'] as { + namespaces: Record }>; + } + ).namespaces[UNBOUND_NAMESPACE_ID]!.models['Note']; + + expect(note).toMatchObject({ fields: {} }); + expect(() => MongoContractSchema.assert(canonical)).not.toThrow(); + }); + it('variant inherits base collection (single-collection)', () => { const ir = interpretOk(` model Task { From 807a1930aeff3e442e8feda747933a2dd0af054b Mon Sep 17 00:00:00 2001 From: Saad Hassan Date: Thu, 13 Aug 2026 00:46:08 +0300 Subject: [PATCH 2/2] test(mongo-contract-psl): assert empty variant fields exactly Signed-off-by: Saad Hassan --- .../contract-psl/test/interpreter.polymorphism.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts index d4725e2cd9a2..f263afad7ceb 100644 --- a/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts +++ b/packages/2-mongo-family/2-authoring/contract-psl/test/interpreter.polymorphism.test.ts @@ -150,7 +150,7 @@ describe('interpretPslDocumentToMongoContract — polymorphism', () => { } `); - expect(modelsOf(ir)['Note']).toMatchObject({ fields: {} }); + expect(modelsOf(ir)['Note']).toHaveProperty('fields', {}); const canonical = canonicalizeContractToObject(ir, { serializeContract: (contract) => JSON.parse(JSON.stringify(contract)) as JsonObject, @@ -162,7 +162,7 @@ describe('interpretPslDocumentToMongoContract — polymorphism', () => { } ).namespaces[UNBOUND_NAMESPACE_ID]!.models['Note']; - expect(note).toMatchObject({ fields: {} }); + expect(note).toHaveProperty('fields', {}); expect(() => MongoContractSchema.assert(canonical)).not.toThrow(); });