@prisma-next/emitter 0.3.0-dev.4 → 0.3.0-dev.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ Contract emission engine that transforms authored data models into canonical JSO
6
6
 
7
7
  The emitter is the core of Prisma Next's contract-first architecture. It takes authored data models (from PSL or TypeScript builders) and produces two deterministic artifacts:
8
8
 
9
- 1. **`contract.json`** — Canonical JSON representation of the data contract with embedded `coreHash` and `profileHash`. Callers may add `_generated` metadata field to indicate it's a generated artifact (excluded from canonicalization/hashing).
9
+ 1. **`contract.json`** — Canonical JSON representation of the data contract with embedded `storageHash` and optional `executionHash`/`profileHash`. Callers may add `_generated` metadata field to indicate it's a generated artifact (excluded from canonicalization/hashing).
10
10
  2. **`contract.d.ts`** — TypeScript type definitions used by query builders and tooling (types-only, no runtime code). Includes warning header comments generated by target family hooks to indicate it's a generated file.
11
11
 
12
12
  The emitter is target-family-agnostic and uses a pluggable hook system (`TargetFamilyHook`) to handle family-specific validation and type generation. This keeps the core thin while allowing SQL, Document, and other target families to extend emission behavior.
@@ -19,7 +19,7 @@ Provide a deterministic, verifiable representation of the application's data con
19
19
 
20
20
  - **Parse**: Accept contract IR (Intermediate Representation) from authoring surfaces
21
21
  - **Validate**: Core structure validation plus family-specific type and structure validation via hooks
22
- - **Canonicalize**: Compute `coreHash` (schema meaning) and `profileHash` (capabilities/pins) from canonical JSON
22
+ - **Canonicalize**: Compute `storageHash` (schema meaning), `executionHash` (execution defaults), and `profileHash` (capabilities/pins) from canonical JSON
23
23
  - **Emit**: Generate `contract.json` and `contract.d.ts` with family-specific type generation
24
24
  - **Descriptor-Agnostic**: The emitter is completely agnostic to how descriptors are produced. It receives pre-assembled `OperationRegistry`, `codecTypeImports`, `operationTypeImports`, and `extensionIds` from the CLI or family helpers—no pack manifest parsing happens inside the emitter.
25
25
 
@@ -90,7 +90,8 @@ flowchart TD
90
90
  - **Note**: `TargetFamilyHook`, `ValidationContext`, and `TypesImportSpec` types are defined in `@prisma-next/contract/types` (shared plane) and re-exported from this package for backward compatibility.
91
91
 
92
92
  ### Hashing (`hashing.ts`)
93
- - `computeCoreHash`: SHA-256 of schema structure (models, storage, relations)
93
+ - `computeStorageHash`: SHA-256 of schema structure (models, storage, relations)
94
+ - `computeExecutionHash`: SHA-256 of execution defaults
94
95
  - `computeProfileHash`: SHA-256 of capabilities and adapter pins
95
96
 
96
97
  ### Canonicalization (`canonicalization.ts`)
@@ -156,7 +157,8 @@ const result = await emit(ir, {
156
157
 
157
158
  // result.contractJson: string (JSON) - canonical JSON without _generated metadata
158
159
  // result.contractDts: string (TypeScript definitions) - includes warning header
159
- // result.coreHash: string
160
+ // result.storageHash: string
161
+ // result.executionHash?: string
160
162
  // result.profileHash?: string
161
163
  ```
162
164
 
@@ -0,0 +1,3 @@
1
+ import { EmitOptions, EmitResult, emit } from "@prisma-next/core-control-plane/emission";
2
+ import { TargetFamilyHook, TypesImportSpec, ValidationContext } from "@prisma-next/contract/types";
3
+ export { type EmitOptions, type EmitResult, type TargetFamilyHook, type TypesImportSpec, type ValidationContext, emit };
@@ -0,0 +1,3 @@
1
+ import { emit } from "@prisma-next/core-control-plane/emission";
2
+
3
+ export { emit };
@@ -1,4 +1,6 @@
1
- import { ContractIR } from '@prisma-next/contract/ir';
1
+ import { ContractIR } from "@prisma-next/contract/ir";
2
+
3
+ //#region test/utils.d.ts
2
4
 
3
5
  /**
4
6
  * Factory function for creating ContractIR objects in tests.
@@ -9,8 +11,9 @@ import { ContractIR } from '@prisma-next/contract/ir';
9
11
  * from the result (useful for testing validation of missing fields).
10
12
  */
11
13
  declare function createContractIR(overrides?: Partial<ContractIR> & {
12
- coreHash?: string;
13
- profileHash?: string;
14
+ storageHash?: string;
15
+ profileHash?: string;
14
16
  }): ContractIR;
15
-
17
+ //#endregion
16
18
  export { createContractIR };
19
+ //# sourceMappingURL=utils.d.mts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.mts","names":[],"sources":["../../test/utils.ts"],"sourcesContent":[],"mappings":";;;;;;AAUA;;;;;;iBAAgB,gBAAA,aACH,QAAQ;;;IAClB"}
@@ -0,0 +1,59 @@
1
+ import { irHeader, irMeta } from "@prisma-next/contract/ir";
2
+
3
+ //#region test/utils.ts
4
+ /**
5
+ * Factory function for creating ContractIR objects in tests.
6
+ * Provides sensible defaults and allows overriding specific fields.
7
+ * Uses the emitter factories internally for consistency.
8
+ *
9
+ * If a field is explicitly set to `undefined` in overrides, it will be omitted
10
+ * from the result (useful for testing validation of missing fields).
11
+ */
12
+ function createContractIR(overrides = {}) {
13
+ const hasTarget = "target" in overrides;
14
+ const hasTargetFamily = "targetFamily" in overrides;
15
+ const hasStorageHash = "storageHash" in overrides;
16
+ const hasSchemaVersion = "schemaVersion" in overrides;
17
+ const hasModels = "models" in overrides;
18
+ const hasRelations = "relations" in overrides;
19
+ const hasStorage = "storage" in overrides;
20
+ const hasCapabilities = "capabilities" in overrides;
21
+ const hasExtensionPacks = "extensionPacks" in overrides;
22
+ const hasMeta = "meta" in overrides;
23
+ const hasSources = "sources" in overrides;
24
+ const headerOpts = {};
25
+ if (hasTarget && overrides.target !== void 0) headerOpts.target = overrides.target;
26
+ else if (!hasTarget) headerOpts.target = "postgres";
27
+ if (hasTargetFamily && overrides.targetFamily !== void 0) headerOpts.targetFamily = overrides.targetFamily;
28
+ else if (!hasTargetFamily) headerOpts.targetFamily = "sql";
29
+ if (hasStorageHash && overrides.storageHash !== void 0) headerOpts.storageHash = overrides.storageHash;
30
+ else if (!hasStorageHash) headerOpts.storageHash = "sha256:test";
31
+ if (overrides.profileHash !== void 0) headerOpts.profileHash = overrides.profileHash;
32
+ const header = irHeader(headerOpts);
33
+ const metaOpts = {};
34
+ if (hasCapabilities && overrides.capabilities !== void 0) metaOpts.capabilities = overrides.capabilities;
35
+ else if (!hasCapabilities) metaOpts.capabilities = {};
36
+ if (hasExtensionPacks && overrides.extensionPacks !== void 0) metaOpts.extensionPacks = overrides.extensionPacks;
37
+ else if (!hasExtensionPacks) metaOpts.extensionPacks = {};
38
+ if (hasMeta && overrides.meta !== void 0) metaOpts.meta = overrides.meta;
39
+ else if (!hasMeta) metaOpts.meta = {};
40
+ if (hasSources && overrides.sources !== void 0) metaOpts.sources = overrides.sources;
41
+ else if (!hasSources) metaOpts.sources = {};
42
+ const meta = irMeta(Object.keys(metaOpts).length > 0 ? metaOpts : void 0);
43
+ return {
44
+ schemaVersion: hasSchemaVersion && overrides.schemaVersion !== void 0 ? overrides.schemaVersion : hasSchemaVersion && overrides.schemaVersion === void 0 ? void 0 : header.schemaVersion,
45
+ target: header.target,
46
+ targetFamily: header.targetFamily,
47
+ capabilities: hasCapabilities && overrides.capabilities === void 0 ? void 0 : !hasCapabilities || overrides.capabilities !== void 0 ? meta.capabilities : {},
48
+ extensionPacks: hasExtensionPacks && overrides.extensionPacks === void 0 ? void 0 : !hasExtensionPacks || overrides.extensionPacks !== void 0 ? meta.extensionPacks : {},
49
+ meta: hasMeta && overrides.meta === void 0 ? void 0 : !hasMeta || overrides.meta !== void 0 ? meta.meta : {},
50
+ sources: hasSources && overrides.sources === void 0 ? void 0 : !hasSources || overrides.sources !== void 0 ? meta.sources : {},
51
+ storage: hasStorage && overrides.storage === void 0 ? void 0 : hasStorage && overrides.storage !== void 0 ? overrides.storage : !hasStorage ? { tables: {} } : {},
52
+ models: hasModels && overrides.models === void 0 ? void 0 : hasModels && overrides.models !== void 0 ? overrides.models : !hasModels ? {} : {},
53
+ relations: hasRelations && overrides.relations === void 0 ? void 0 : hasRelations && overrides.relations !== void 0 ? overrides.relations : !hasRelations ? {} : {}
54
+ };
55
+ }
56
+
57
+ //#endregion
58
+ export { createContractIR };
59
+ //# sourceMappingURL=utils.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.mjs","names":["headerOpts: {\n target?: string;\n targetFamily?: string;\n storageHash?: string;\n profileHash?: string;\n }","metaOpts: {\n capabilities?: Record<string, Record<string, boolean>>;\n extensionPacks?: Record<string, unknown>;\n meta?: Record<string, unknown>;\n sources?: Record<string, unknown>;\n }"],"sources":["../../test/utils.ts"],"sourcesContent":["import { type ContractIR, irHeader, irMeta } from '@prisma-next/contract/ir';\n\n/**\n * Factory function for creating ContractIR objects in tests.\n * Provides sensible defaults and allows overriding specific fields.\n * Uses the emitter factories internally for consistency.\n *\n * If a field is explicitly set to `undefined` in overrides, it will be omitted\n * from the result (useful for testing validation of missing fields).\n */\nexport function createContractIR(\n overrides: Partial<ContractIR> & { storageHash?: string; profileHash?: string } = {},\n): ContractIR {\n // Check if fields are explicitly undefined (not just missing)\n const hasTarget = 'target' in overrides;\n const hasTargetFamily = 'targetFamily' in overrides;\n const hasStorageHash = 'storageHash' in overrides;\n const hasSchemaVersion = 'schemaVersion' in overrides;\n const hasModels = 'models' in overrides;\n const hasRelations = 'relations' in overrides;\n const hasStorage = 'storage' in overrides;\n const hasCapabilities = 'capabilities' in overrides;\n const hasExtensionPacks = 'extensionPacks' in overrides;\n const hasMeta = 'meta' in overrides;\n const hasSources = 'sources' in overrides;\n\n // Build header, omitting fields that are explicitly undefined\n const headerOpts: {\n target?: string;\n targetFamily?: string;\n storageHash?: string;\n profileHash?: string;\n } = {};\n\n if (hasTarget && overrides.target !== undefined) {\n headerOpts.target = overrides.target;\n } else if (!hasTarget) {\n headerOpts.target = 'postgres';\n }\n\n if (hasTargetFamily && overrides.targetFamily !== undefined) {\n headerOpts.targetFamily = overrides.targetFamily;\n } else if (!hasTargetFamily) {\n headerOpts.targetFamily = 'sql';\n }\n\n if (hasStorageHash && overrides.storageHash !== undefined) {\n headerOpts.storageHash = overrides.storageHash;\n } else if (!hasStorageHash) {\n headerOpts.storageHash = 'sha256:test';\n }\n\n // profileHash is not part of ContractIR, but we can accept it for header creation\n if (overrides.profileHash !== undefined) {\n headerOpts.profileHash = overrides.profileHash;\n }\n\n const header = irHeader(\n headerOpts as {\n target: string;\n targetFamily: string;\n storageHash: string;\n profileHash?: string;\n },\n );\n\n // Build meta, handling explicitly undefined fields\n // If a field is explicitly undefined, we'll omit it from the result later\n const metaOpts: {\n capabilities?: Record<string, Record<string, boolean>>;\n extensionPacks?: Record<string, unknown>;\n meta?: Record<string, unknown>;\n sources?: Record<string, unknown>;\n } = {};\n\n if (hasCapabilities && overrides.capabilities !== undefined) {\n metaOpts.capabilities = overrides.capabilities;\n } else if (!hasCapabilities) {\n metaOpts.capabilities = {};\n }\n\n if (hasExtensionPacks && overrides.extensionPacks !== undefined) {\n metaOpts.extensionPacks = overrides.extensionPacks;\n } else if (!hasExtensionPacks) {\n metaOpts.extensionPacks = {};\n }\n\n if (hasMeta && overrides.meta !== undefined) {\n metaOpts.meta = overrides.meta;\n } else if (!hasMeta) {\n metaOpts.meta = {};\n }\n\n if (hasSources && overrides.sources !== undefined) {\n metaOpts.sources = overrides.sources;\n } else if (!hasSources) {\n metaOpts.sources = {};\n }\n\n const meta = irMeta(Object.keys(metaOpts).length > 0 ? metaOpts : undefined);\n\n // Build result by constructing the object directly (ContractIR doesn't include storageHash/profileHash)\n // When fields are explicitly undefined, include them as undefined (tests use type assertions to bypass TS)\n const result = {\n schemaVersion:\n hasSchemaVersion && overrides.schemaVersion !== undefined\n ? overrides.schemaVersion\n : hasSchemaVersion && overrides.schemaVersion === undefined\n ? (undefined as unknown as string)\n : header.schemaVersion,\n target: header.target,\n targetFamily: header.targetFamily,\n // Only include meta fields if they're not explicitly undefined\n capabilities:\n hasCapabilities && overrides.capabilities === undefined\n ? (undefined as unknown as Record<string, Record<string, boolean>>)\n : !hasCapabilities || overrides.capabilities !== undefined\n ? meta.capabilities\n : ({} as Record<string, Record<string, boolean>>),\n extensionPacks:\n hasExtensionPacks && overrides.extensionPacks === undefined\n ? (undefined as unknown as Record<string, unknown>)\n : !hasExtensionPacks || overrides.extensionPacks !== undefined\n ? meta.extensionPacks\n : ({} as Record<string, unknown>),\n meta:\n hasMeta && overrides.meta === undefined\n ? (undefined as unknown as Record<string, unknown>)\n : !hasMeta || overrides.meta !== undefined\n ? meta.meta\n : ({} as Record<string, unknown>),\n sources:\n hasSources && overrides.sources === undefined\n ? (undefined as unknown as Record<string, unknown>)\n : !hasSources || overrides.sources !== undefined\n ? meta.sources\n : ({} as Record<string, unknown>),\n // Only include family sections if they're not explicitly undefined\n storage:\n hasStorage && overrides.storage === undefined\n ? (undefined as unknown as Record<string, unknown>)\n : hasStorage && overrides.storage !== undefined\n ? (overrides.storage as Record<string, unknown>)\n : !hasStorage\n ? ({ tables: {} } as Record<string, unknown>)\n : ({} as Record<string, unknown>),\n models:\n hasModels && overrides.models === undefined\n ? (undefined as unknown as Record<string, unknown>)\n : hasModels && overrides.models !== undefined\n ? (overrides.models as Record<string, unknown>)\n : !hasModels\n ? {}\n : ({} as Record<string, unknown>),\n relations:\n hasRelations && overrides.relations === undefined\n ? (undefined as unknown as Record<string, unknown>)\n : hasRelations && overrides.relations !== undefined\n ? (overrides.relations as Record<string, unknown>)\n : !hasRelations\n ? {}\n : ({} as Record<string, unknown>),\n } as ContractIR;\n\n return result;\n}\n"],"mappings":";;;;;;;;;;;AAUA,SAAgB,iBACd,YAAkF,EAAE,EACxE;CAEZ,MAAM,YAAY,YAAY;CAC9B,MAAM,kBAAkB,kBAAkB;CAC1C,MAAM,iBAAiB,iBAAiB;CACxC,MAAM,mBAAmB,mBAAmB;CAC5C,MAAM,YAAY,YAAY;CAC9B,MAAM,eAAe,eAAe;CACpC,MAAM,aAAa,aAAa;CAChC,MAAM,kBAAkB,kBAAkB;CAC1C,MAAM,oBAAoB,oBAAoB;CAC9C,MAAM,UAAU,UAAU;CAC1B,MAAM,aAAa,aAAa;CAGhC,MAAMA,aAKF,EAAE;AAEN,KAAI,aAAa,UAAU,WAAW,OACpC,YAAW,SAAS,UAAU;UACrB,CAAC,UACV,YAAW,SAAS;AAGtB,KAAI,mBAAmB,UAAU,iBAAiB,OAChD,YAAW,eAAe,UAAU;UAC3B,CAAC,gBACV,YAAW,eAAe;AAG5B,KAAI,kBAAkB,UAAU,gBAAgB,OAC9C,YAAW,cAAc,UAAU;UAC1B,CAAC,eACV,YAAW,cAAc;AAI3B,KAAI,UAAU,gBAAgB,OAC5B,YAAW,cAAc,UAAU;CAGrC,MAAM,SAAS,SACb,WAMD;CAID,MAAMC,WAKF,EAAE;AAEN,KAAI,mBAAmB,UAAU,iBAAiB,OAChD,UAAS,eAAe,UAAU;UACzB,CAAC,gBACV,UAAS,eAAe,EAAE;AAG5B,KAAI,qBAAqB,UAAU,mBAAmB,OACpD,UAAS,iBAAiB,UAAU;UAC3B,CAAC,kBACV,UAAS,iBAAiB,EAAE;AAG9B,KAAI,WAAW,UAAU,SAAS,OAChC,UAAS,OAAO,UAAU;UACjB,CAAC,QACV,UAAS,OAAO,EAAE;AAGpB,KAAI,cAAc,UAAU,YAAY,OACtC,UAAS,UAAU,UAAU;UACpB,CAAC,WACV,UAAS,UAAU,EAAE;CAGvB,MAAM,OAAO,OAAO,OAAO,KAAK,SAAS,CAAC,SAAS,IAAI,WAAW,OAAU;AAiE5E,QA7De;EACb,eACE,oBAAoB,UAAU,kBAAkB,SAC5C,UAAU,gBACV,oBAAoB,UAAU,kBAAkB,SAC7C,SACD,OAAO;EACf,QAAQ,OAAO;EACf,cAAc,OAAO;EAErB,cACE,mBAAmB,UAAU,iBAAiB,SACzC,SACD,CAAC,mBAAmB,UAAU,iBAAiB,SAC7C,KAAK,eACJ,EAAE;EACX,gBACE,qBAAqB,UAAU,mBAAmB,SAC7C,SACD,CAAC,qBAAqB,UAAU,mBAAmB,SACjD,KAAK,iBACJ,EAAE;EACX,MACE,WAAW,UAAU,SAAS,SACzB,SACD,CAAC,WAAW,UAAU,SAAS,SAC7B,KAAK,OACJ,EAAE;EACX,SACE,cAAc,UAAU,YAAY,SAC/B,SACD,CAAC,cAAc,UAAU,YAAY,SACnC,KAAK,UACJ,EAAE;EAEX,SACE,cAAc,UAAU,YAAY,SAC/B,SACD,cAAc,UAAU,YAAY,SACjC,UAAU,UACX,CAAC,aACE,EAAE,QAAQ,EAAE,EAAE,GACd,EAAE;EACb,QACE,aAAa,UAAU,WAAW,SAC7B,SACD,aAAa,UAAU,WAAW,SAC/B,UAAU,SACX,CAAC,YACC,EAAE,GACD,EAAE;EACb,WACE,gBAAgB,UAAU,cAAc,SACnC,SACD,gBAAgB,UAAU,cAAc,SACrC,UAAU,YACX,CAAC,eACC,EAAE,GACD,EAAE;EACd"}
package/package.json CHANGED
@@ -1,43 +1,51 @@
1
1
  {
2
2
  "name": "@prisma-next/emitter",
3
- "version": "0.3.0-dev.4",
3
+ "version": "0.3.0-dev.41",
4
4
  "type": "module",
5
5
  "sideEffects": false,
6
6
  "files": [
7
- "dist"
7
+ "dist",
8
+ "src",
9
+ "test"
8
10
  ],
9
11
  "dependencies": {
10
12
  "arktype": "^2.0.0",
11
- "@prisma-next/contract": "0.3.0-dev.4",
12
- "@prisma-next/core-control-plane": "0.3.0-dev.4"
13
+ "@prisma-next/contract": "0.3.0-dev.41",
14
+ "@prisma-next/core-control-plane": "0.3.0-dev.41"
13
15
  },
14
16
  "devDependencies": {
15
17
  "@types/node": "24.10.4",
16
- "@vitest/coverage-v8": "4.0.16",
17
- "tsup": "8.5.1",
18
+ "tsdown": "0.18.4",
18
19
  "typescript": "5.9.3",
19
- "vitest": "4.0.16",
20
- "@prisma-next/operations": "0.3.0-dev.4",
21
- "@prisma-next/test-utils": "0.0.1"
20
+ "vitest": "4.0.17",
21
+ "@prisma-next/test-utils": "0.0.1",
22
+ "@prisma-next/operations": "0.3.0-dev.41",
23
+ "@prisma-next/tsconfig": "0.0.0",
24
+ "@prisma-next/tsdown": "0.0.0"
22
25
  },
23
26
  "exports": {
24
27
  ".": {
25
- "types": "./dist/exports/index.d.ts",
26
- "import": "./dist/exports/index.js"
28
+ "types": "./dist/exports/index.d.mts",
29
+ "import": "./dist/exports/index.mjs"
27
30
  },
28
31
  "./test/utils": {
29
- "types": "./dist/test/utils.d.ts",
30
- "import": "./dist/test/utils.js"
32
+ "types": "./dist/test/utils.d.mts",
33
+ "import": "./dist/test/utils.mjs"
31
34
  }
32
35
  },
36
+ "repository": {
37
+ "type": "git",
38
+ "url": "https://github.com/prisma/prisma-next.git",
39
+ "directory": "packages/1-framework/3-tooling/emitter"
40
+ },
33
41
  "scripts": {
34
- "build": "tsup --config tsup.config.ts",
42
+ "build": "tsdown",
35
43
  "test": "vitest run",
36
44
  "test:coverage": "vitest run --coverage",
37
45
  "typecheck": "tsc --project tsconfig.json --noEmit",
38
- "lint": "biome check . --config-path ../../../../biome.json --error-on-warnings",
39
- "lint:fix": "biome check --write . --config-path ../../../../biome.json",
40
- "lint:fix:unsafe": "biome check --write --unsafe . --config-path ../../../../biome.json",
41
- "clean": "node ../../../../scripts/clean.mjs"
46
+ "lint": "biome check . --error-on-warnings",
47
+ "lint:fix": "biome check --write .",
48
+ "lint:fix:unsafe": "biome check --write --unsafe .",
49
+ "clean": "rm -rf dist dist-tsc dist-tsc-prod coverage .tmp-output"
42
50
  }
43
51
  }
@@ -0,0 +1,9 @@
1
+ // Re-export types from @prisma-next/contract for backward compatibility
2
+ export type {
3
+ TargetFamilyHook,
4
+ TypesImportSpec,
5
+ ValidationContext,
6
+ } from '@prisma-next/contract/types';
7
+ export type { EmitOptions, EmitResult } from '@prisma-next/core-control-plane/emission';
8
+ // Re-export emit function and types from core-control-plane
9
+ export { emit } from '@prisma-next/core-control-plane/emission';
@@ -0,0 +1,7 @@
1
+ // Re-export types from @prisma-next/contract for backward compatibility
2
+ // These types were moved to @prisma-next/contract to resolve dependency violations
3
+ export type {
4
+ TargetFamilyHook,
5
+ TypesImportSpec,
6
+ ValidationContext,
7
+ } from '@prisma-next/contract/types';
@@ -0,0 +1,331 @@
1
+ import { canonicalizeContract } from '@prisma-next/core-control-plane/emission';
2
+ import { describe, expect, it } from 'vitest';
3
+ import { createContractIR } from './utils';
4
+
5
+ describe('canonicalization', () => {
6
+ it('orders top-level sections correctly', () => {
7
+ const ir = createContractIR({
8
+ capabilities: { postgres: { jsonAgg: true } },
9
+ meta: { source: 'test' },
10
+ });
11
+
12
+ const result = canonicalizeContract(ir);
13
+ const parsed = JSON.parse(result) as Record<string, unknown>;
14
+
15
+ const keys = Object.keys(parsed);
16
+ const schemaVersionIndex = keys.indexOf('schemaVersion');
17
+ const targetFamilyIndex = keys.indexOf('targetFamily');
18
+ const targetIndex = keys.indexOf('target');
19
+ const modelsIndex = keys.indexOf('models');
20
+ const storageIndex = keys.indexOf('storage');
21
+ const capabilitiesIndex = keys.indexOf('capabilities');
22
+ const metaIndex = keys.indexOf('meta');
23
+
24
+ expect(schemaVersionIndex).toBeLessThan(targetFamilyIndex);
25
+ expect(targetFamilyIndex).toBeLessThan(targetIndex);
26
+ expect(targetIndex).toBeLessThan(modelsIndex);
27
+ expect(modelsIndex).toBeLessThan(storageIndex);
28
+ expect(storageIndex).toBeLessThan(capabilitiesIndex);
29
+ expect(capabilitiesIndex).toBeLessThan(metaIndex);
30
+ });
31
+
32
+ it('omits nullable false from columns', () => {
33
+ const ir = createContractIR({
34
+ storage: {
35
+ tables: {
36
+ user: {
37
+ columns: {
38
+ id: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false },
39
+ email: { codecId: 'pg/text@1', nativeType: 'text', nullable: true },
40
+ },
41
+ },
42
+ },
43
+ },
44
+ });
45
+
46
+ const result = canonicalizeContract(ir);
47
+ const parsed = JSON.parse(result) as Record<string, unknown>;
48
+ const storage = parsed['storage'] as Record<string, unknown>;
49
+ const tables = storage['tables'] as Record<string, unknown>;
50
+ const user = tables['user'] as Record<string, unknown>;
51
+ const columns = user['columns'] as Record<string, unknown>;
52
+ const id = columns['id'] as Record<string, unknown>;
53
+ const email = columns['email'] as Record<string, unknown>;
54
+ expect(id['nullable']).toBeUndefined();
55
+ expect(email['nullable']).toBe(true);
56
+ });
57
+
58
+ it.each([
59
+ { nullable: false },
60
+ { nullable: undefined },
61
+ ])('omits nullable:false for columns with defaults (nullable=$nullable)', ({ nullable }) => {
62
+ const ir = createContractIR({
63
+ storage: {
64
+ tables: {
65
+ user: {
66
+ columns: {
67
+ created_at: {
68
+ codecId: 'pg/timestamptz@1',
69
+ nativeType: 'timestamptz',
70
+ nullable,
71
+ default: { kind: 'function', expression: 'now()' },
72
+ },
73
+ updated_at: {
74
+ codecId: 'pg/timestamptz@1',
75
+ nativeType: 'timestamptz',
76
+ nullable: true,
77
+ },
78
+ },
79
+ },
80
+ },
81
+ },
82
+ });
83
+
84
+ const result = canonicalizeContract(ir);
85
+ const parsed = JSON.parse(result) as Record<string, unknown>;
86
+ const storage = parsed['storage'] as Record<string, unknown>;
87
+ const tables = storage['tables'] as Record<string, unknown>;
88
+ const user = tables['user'] as Record<string, unknown>;
89
+ const columns = user['columns'] as Record<string, unknown>;
90
+ const createdAt = columns['created_at'] as Record<string, unknown>;
91
+ const updatedAt = columns['updated_at'] as Record<string, unknown>;
92
+ expect(createdAt['nullable']).toBeUndefined();
93
+ expect(updatedAt['nullable']).toBe(true);
94
+ });
95
+
96
+ it('preserves nullable:true for columns with defaults', () => {
97
+ const ir = createContractIR({
98
+ storage: {
99
+ tables: {
100
+ user: {
101
+ columns: {
102
+ bio: {
103
+ codecId: 'pg/text@1',
104
+ nativeType: 'text',
105
+ nullable: true,
106
+ default: { kind: 'literal', value: '' },
107
+ },
108
+ },
109
+ },
110
+ },
111
+ },
112
+ });
113
+
114
+ const result = canonicalizeContract(ir);
115
+ const parsed = JSON.parse(result) as Record<string, unknown>;
116
+ const storage = parsed['storage'] as Record<string, unknown>;
117
+ const tables = storage['tables'] as Record<string, unknown>;
118
+ const user = tables['user'] as Record<string, unknown>;
119
+ const columns = user['columns'] as Record<string, unknown>;
120
+ const bio = columns['bio'] as Record<string, unknown>;
121
+ expect(bio['nullable']).toBe(true);
122
+ expect(bio['default']).toEqual({ kind: 'literal', value: '' });
123
+ });
124
+
125
+ it('omits empty arrays and objects except required ones', () => {
126
+ const ir = createContractIR();
127
+
128
+ const result = canonicalizeContract(ir);
129
+ const parsed = JSON.parse(result);
130
+ expect(parsed).toMatchObject({
131
+ models: expect.anything(),
132
+ storage: {
133
+ tables: expect.anything(),
134
+ },
135
+ });
136
+ // Required top-level fields (capabilities, extensionPacks, meta, relations, sources) are preserved even when empty
137
+ // because they are required by ContractIR and needed for round-trip tests
138
+ expect(parsed).toMatchObject({
139
+ capabilities: expect.anything(),
140
+ extensionPacks: expect.anything(),
141
+ meta: expect.anything(),
142
+ relations: expect.anything(),
143
+ sources: expect.anything(),
144
+ });
145
+ });
146
+
147
+ it('preserves semantic array order for column lists', () => {
148
+ const ir = createContractIR({
149
+ storage: {
150
+ tables: {
151
+ user: {
152
+ columns: {
153
+ first: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
154
+ second: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
155
+ },
156
+ primaryKey: {
157
+ columns: ['second', 'first'],
158
+ },
159
+ },
160
+ },
161
+ },
162
+ });
163
+
164
+ const result1 = canonicalizeContract(ir);
165
+
166
+ const ir2 = createContractIR({
167
+ storage: {
168
+ tables: {
169
+ user: {
170
+ columns: {
171
+ first: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
172
+ second: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
173
+ },
174
+ primaryKey: {
175
+ columns: ['first', 'second'],
176
+ },
177
+ },
178
+ },
179
+ },
180
+ });
181
+
182
+ const result2 = canonicalizeContract(ir2);
183
+
184
+ expect(result1).not.toBe(result2);
185
+ });
186
+
187
+ it('sorts indexes by canonical name', () => {
188
+ const ir = createContractIR({
189
+ storage: {
190
+ tables: {
191
+ user: {
192
+ columns: {
193
+ id: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false },
194
+ },
195
+ indexes: [
196
+ { columns: ['id'], name: 'user_email_idx' },
197
+ { columns: ['id'], name: 'user_name_idx' },
198
+ ],
199
+ },
200
+ },
201
+ },
202
+ });
203
+
204
+ const result = canonicalizeContract(ir);
205
+ const parsed = JSON.parse(result) as Record<string, unknown>;
206
+ const storage = parsed['storage'] as Record<string, unknown>;
207
+ const tables = storage['tables'] as Record<string, unknown>;
208
+ const user = tables['user'] as Record<string, unknown>;
209
+ const indexes = user['indexes'] as Array<{ name: string }>;
210
+ const indexNames = indexes.map((idx) => idx.name);
211
+ expect(indexNames).toEqual(['user_email_idx', 'user_name_idx']);
212
+ });
213
+
214
+ it('sorts uniques by canonical name', () => {
215
+ const ir = createContractIR({
216
+ storage: {
217
+ tables: {
218
+ user: {
219
+ columns: {
220
+ id: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false },
221
+ email: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
222
+ username: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
223
+ },
224
+ uniques: [
225
+ { columns: ['username'], name: 'user_username_key' },
226
+ { columns: ['email'], name: 'user_email_key' },
227
+ ],
228
+ },
229
+ },
230
+ },
231
+ });
232
+
233
+ const result = canonicalizeContract(ir);
234
+ const parsed = JSON.parse(result) as Record<string, unknown>;
235
+ const storage = parsed['storage'] as Record<string, unknown>;
236
+ const tables = storage['tables'] as Record<string, unknown>;
237
+ const user = tables['user'] as Record<string, unknown>;
238
+ const uniques = user['uniques'] as Array<{ name: string }>;
239
+ const uniqueNames = uniques.map((u) => u.name);
240
+ expect(uniqueNames).toEqual(['user_email_key', 'user_username_key']);
241
+ });
242
+
243
+ it('preserves column order in composite unique constraints', () => {
244
+ const ir = createContractIR({
245
+ storage: {
246
+ tables: {
247
+ user: {
248
+ columns: {
249
+ id: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false },
250
+ first_name: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
251
+ last_name: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
252
+ },
253
+ uniques: [{ columns: ['last_name', 'first_name'], name: 'user_name_key' }],
254
+ },
255
+ },
256
+ },
257
+ });
258
+
259
+ const result = canonicalizeContract(ir);
260
+ const parsed = JSON.parse(result) as Record<string, unknown>;
261
+ const storage = parsed['storage'] as Record<string, unknown>;
262
+ const tables = storage['tables'] as Record<string, unknown>;
263
+ const user = tables['user'] as Record<string, unknown>;
264
+ const uniques = user['uniques'] as Array<{ columns: string[] }>;
265
+ expect(uniques[0]!.columns).toEqual(['last_name', 'first_name']);
266
+ });
267
+
268
+ it('sorts nested object keys lexicographically', () => {
269
+ const ir = createContractIR({
270
+ storage: {
271
+ tables: {
272
+ user: {
273
+ columns: {
274
+ z_field: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
275
+ a_field: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
276
+ m_field: { codecId: 'pg/text@1', nativeType: 'text', nullable: false },
277
+ },
278
+ },
279
+ },
280
+ },
281
+ });
282
+
283
+ const result = canonicalizeContract(ir);
284
+ const parsed = JSON.parse(result) as Record<string, unknown>;
285
+ const storage = parsed['storage'] as Record<string, unknown>;
286
+ const tables = storage['tables'] as Record<string, unknown>;
287
+ const user = tables['user'] as Record<string, unknown>;
288
+ const columns = user['columns'] as Record<string, unknown>;
289
+ const columnKeys = Object.keys(columns);
290
+ expect(columnKeys).toEqual(['a_field', 'm_field', 'z_field']);
291
+ });
292
+
293
+ it('sorts extension namespaces lexicographically', () => {
294
+ const ir = createContractIR({
295
+ extensionPacks: {
296
+ pgvector: { version: '0.0.1' },
297
+ postgres: { version: '0.0.1' },
298
+ another: { version: '0.0.1' },
299
+ },
300
+ });
301
+
302
+ const result = canonicalizeContract(ir);
303
+ const parsed = JSON.parse(result) as Record<string, unknown>;
304
+ const extensionPacks = parsed['extensionPacks'] as Record<string, unknown>;
305
+ const extensionKeys = Object.keys(extensionPacks);
306
+ expect(extensionKeys).toEqual(['another', 'pgvector', 'postgres']);
307
+ });
308
+
309
+ it('omits generated false', () => {
310
+ const ir = createContractIR({
311
+ storage: {
312
+ tables: {
313
+ user: {
314
+ columns: {
315
+ id: { codecId: 'pg/int4@1', nativeType: 'int4', nullable: false, generated: false },
316
+ },
317
+ },
318
+ },
319
+ },
320
+ });
321
+
322
+ const result = canonicalizeContract(ir);
323
+ const parsed = JSON.parse(result) as Record<string, unknown>;
324
+ const storage = parsed['storage'] as Record<string, unknown>;
325
+ const tables = storage['tables'] as Record<string, unknown>;
326
+ const user = tables['user'] as Record<string, unknown>;
327
+ const columns = user['columns'] as Record<string, unknown>;
328
+ const id = columns['id'] as Record<string, unknown>;
329
+ expect(id['generated']).toBeUndefined();
330
+ });
331
+ });