From 5769701db544a57a345af5efb72e93bc6bb29559 Mon Sep 17 00:00:00 2001 From: Fedor Sakharov Date: Fri, 2 Oct 2026 09:35:31 +0200 Subject: [PATCH 1/6] Disallow zero ontology type versions --- .changeset/nonzero-ontology-versions.md | 5 + .../src/pages/shared/data-type.tsx | 6 +- .../shared/data-type/data-type-header.tsx | 3 +- .../edit-bar-type-editor.test.tsx | 117 ++++++++++++++++++ .../entity-type-page/edit-bar-type-editor.tsx | 31 ++--- .../shared/entity-type-header.tsx | 3 +- .../src/pages/shared/entity-type.tsx | 6 +- .../type-system/rust/src/ontology/id/mod.rs | 60 ++++++--- .../type-system/typescript/src/native/url.ts | 37 ++++-- .../type-system/typescript/test/url.test.ts | 49 +++++++- .../graph/api/src/rest/legacy/data_type.rs | 24 +++- .../graph/api/src/rest/legacy/entity_type.rs | 24 +++- .../api/src/rest/legacy/property_type.rs | 24 +++- .../legacy/utoipa_typedef/subgraph/edges.rs | 4 +- .../src/policies/cedar/expression_tree.rs | 3 +- .../rust/src/policies/resource/data_type.rs | 8 +- .../rust/src/policies/resource/entity_type.rs | 8 +- .../src/policies/resource/property_type.rs | 8 +- .../snapshot/ontology/data_type/channel.rs | 17 +-- .../snapshot/ontology/entity_type/channel.rs | 17 +-- .../postgres-store/src/store/postgres/mod.rs | 19 +-- .../src/store/postgres/ontology/data_type.rs | 9 +- .../store/postgres/ontology/entity_type.rs | 9 +- .../store/postgres/ontology/property_type.rs | 8 +- .../src/store/postgres/query/compile/tests.rs | 4 +- .../postgres-store/tests/deletion/main.rs | 5 +- .../@local/graph/store/rust/src/filter/mod.rs | 4 +- .../graph/integration/postgres/clustering.rs | 2 +- tests/graph/integration/postgres/drafts.rs | 3 +- .../postgres/email_filter_protection.rs | 25 ++-- tests/graph/integration/postgres/entity.rs | 9 +- tests/graph/integration/postgres/links.rs | 15 +-- tests/graph/integration/postgres/read_only.rs | 4 +- .../integration/postgres/semantic_search.rs | 3 +- tests/graph/integration/postgres/sorting.rs | 5 +- tests/graph/integration/postgres/table.rs | 13 +- .../rust/src/seeding/producer/data_type.rs | 4 +- .../rust/src/seeding/producer/entity_type.rs | 4 +- .../src/seeding/producer/property_type.rs | 4 +- 39 files changed, 443 insertions(+), 160 deletions(-) create mode 100644 .changeset/nonzero-ontology-versions.md create mode 100644 apps/hash-frontend/src/pages/shared/entity-type-page/edit-bar-type-editor.test.tsx diff --git a/.changeset/nonzero-ontology-versions.md b/.changeset/nonzero-ontology-versions.md new file mode 100644 index 00000000000..348767273dc --- /dev/null +++ b/.changeset/nonzero-ontology-versions.md @@ -0,0 +1,5 @@ +--- +"@blockprotocol/type-system": patch +--- + +Reject zero major versions in versioned URLs. Require ontology version constructors to receive integers between 1 and 4294967295. diff --git a/apps/hash-frontend/src/pages/shared/data-type.tsx b/apps/hash-frontend/src/pages/shared/data-type.tsx index dd711d9cca3..9724150bc91 100644 --- a/apps/hash-frontend/src/pages/shared/data-type.tsx +++ b/apps/hash-frontend/src/pages/shared/data-type.tsx @@ -10,7 +10,6 @@ import { getRoots } from "@blockprotocol/graph/stdlib"; import { compareOntologyTypeVersions, extractVersion, - makeOntologyTypeVersion, versionedUrlFromComponents, } from "@blockprotocol/type-system"; import { deserializeQueryDataTypeSubgraphResponse } from "@local/hash-graph-sdk/data-type"; @@ -331,9 +330,7 @@ export const DataType = ({ throw new Error("Cannot render data type without data type"); } - const currentVersion = draftNewDataType - ? makeOntologyTypeVersion({ major: 0 }) - : extractVersion(dataType.schema.$id); + const currentVersion = extractVersion(dataType.schema.$id); const isLatest = !requestedVersion || requestedVersion === latestVersion; @@ -374,6 +371,7 @@ export const DataType = ({ {!isReadOnly && ( ({ + dirtyFields: {} as Record, + isSubmitting: false, +})); + +const confirmButton = vi.hoisted(() => ({ disabled: false })); + +vi.mock("@hashintel/type-editor", () => ({ + useEntityTypeFormState: () => formState, +})); + +vi.mock("@hashintel/design-system", () => ({ + FontAwesomeIcon: () => null, +})); + +vi.mock("../../../shared/icons/svg", () => ({ + PencilSimpleLine: () => null, +})); + +vi.mock("../shared/edit-bar-contents", () => ({ + EditBarCollapse: ({ + children, + in: visible, + }: { + children: ReactNode; + in: boolean; + }) => (visible ? children : null), + EditBarContainer: ({ children }: { children: ReactNode }) => children, + EditBarContents: ({ + label, + confirmButtonProps, + discardButtonProps, + }: { + label: string; + confirmButtonProps: { children: ReactNode; disabled: boolean }; + discardButtonProps: { children: ReactNode }; + }) => { + confirmButton.disabled = confirmButtonProps.disabled; + return ( + <> + {label} {confirmButtonProps.children} {discardButtonProps.children} + + ); + }, + useFreezeScrollWhileTransitioning: () => null, +})); + +const renderEditor = (isDraft: boolean, major = 1) => + renderToStaticMarkup( + , + ); + +describe("EditBarTypeEditor", () => { + beforeEach(() => { + formState.dirtyFields = {}; + confirmButton.disabled = false; + }); + + it("shows creation actions for a pristine draft at version one", () => { + const markup = renderEditor(true); + + expect(markup).toContain("this type has not yet been created"); + expect(markup).toContain("Create"); + expect(markup).toContain("Discard this type"); + }); + + it("shows update actions for an edited existing type at version one", () => { + formState.dirtyFields = { title: true }; + + const markup = renderEditor(false); + + expect(markup).toContain("Version 1 -> 2"); + expect(markup).toContain("Publish update"); + expect(markup).toContain("Discard changes"); + expect(markup).not.toContain("this type has not yet been created"); + }); + + it("hides the edit bar for a pristine existing type", () => { + expect(renderEditor(false)).toBe(""); + }); + + it("renders a pristine existing type at the maximum version", () => { + expect(renderEditor(false, 4_294_967_295)).toBe(""); + }); + + it("disables publishing an update at the maximum version", () => { + formState.dirtyFields = { title: true }; + + expect(renderEditor(false, 4_294_967_295)).toContain( + "this type has reached the maximum version and cannot be updated", + ); + expect(confirmButton.disabled).toBe(true); + }); + + it("allows publishing the last available version", () => { + formState.dirtyFields = { title: true }; + + expect(renderEditor(false, 4_294_967_294)).toContain( + "Version 4294967294 -> 4294967295", + ); + expect(confirmButton.disabled).toBe(false); + }); +}); diff --git a/apps/hash-frontend/src/pages/shared/entity-type-page/edit-bar-type-editor.tsx b/apps/hash-frontend/src/pages/shared/entity-type-page/edit-bar-type-editor.tsx index ff80b728950..7b51219bfc2 100644 --- a/apps/hash-frontend/src/pages/shared/entity-type-page/edit-bar-type-editor.tsx +++ b/apps/hash-frontend/src/pages/shared/entity-type-page/edit-bar-type-editor.tsx @@ -3,7 +3,6 @@ import { useState } from "react"; import { incrementOntologyTypeVersion, - makeOntologyTypeVersion, type OntologyTypeVersion, } from "@blockprotocol/type-system"; import { FontAwesomeIcon } from "@hashintel/design-system"; @@ -20,7 +19,9 @@ import { import type { ButtonProps } from "../../../shared/ui/button"; import type { EntityTypeEditorFormData } from "@hashintel/type-editor"; -const useFrozenValue = (value: T): T => { +const useFrozenValue = ( + value: T, +): T => { const { dirtyFields } = useEntityTypeFormState(); const [frozen, setFrozen] = useState(value); @@ -35,32 +36,38 @@ const useFrozenValue = (value: T): T => { export const EditBarTypeEditor = ({ gentleErrorStyling, currentVersion, + isDraft, discardButtonProps, errorMessage, }: { gentleErrorStyling: boolean; currentVersion: OntologyTypeVersion; + isDraft: boolean; discardButtonProps: Partial; errorMessage?: string; }) => { const { dirtyFields, isSubmitting } = useEntityTypeFormState(); const frozenVersion = useFrozenValue(currentVersion); + const frozenIsDraft = useFrozenValue(isDraft); const ref = useFreezeScrollWhileTransitioning(); - const zeroVersion = makeOntologyTypeVersion({ major: 0 }); - const collapseIn = - currentVersion === zeroVersion || Object.keys(dirtyFields).length > 0; + const collapseIn = isDraft || Object.keys(dirtyFields).length > 0; const frozenDiscardButtonProps = useFrozenValue(discardButtonProps); const frozenSubmitting = useFrozenValue(isSubmitting); + const isVersionExhausted = + !frozenIsDraft && + Number.parseInt(frozenVersion.toString(), 10) === 4_294_967_295; let label; if (errorMessage) { label = `before saving${errorMessage ? `: ${errorMessage}` : ""}`; - } else if (frozenVersion === zeroVersion) { + } else if (frozenIsDraft) { label = "– this type has not yet been created"; + } else if (isVersionExhausted) { + label = "– this type has reached the maximum version and cannot be updated"; } else { label = `Version ${frozenVersion.toString()} -> ${incrementOntologyTypeVersion(frozenVersion).toString()}`; } @@ -74,7 +81,7 @@ export const EditBarTypeEditor = ({ ) : ( @@ -83,10 +90,7 @@ export const EditBarTypeEditor = ({ title={errorMessage ? "Changes required" : "Currently editing"} label={label} discardButtonProps={{ - children: - frozenVersion === zeroVersion - ? "Discard this type" - : "Discard changes", + children: frozenIsDraft ? "Discard this type" : "Discard changes", disabled: frozenSubmitting, sx: errorMessage ? ({ palette }) => ({ @@ -104,10 +108,9 @@ export const EditBarTypeEditor = ({ ...frozenDiscardButtonProps, }} confirmButtonProps={{ - children: - frozenVersion === zeroVersion ? "Create" : "Publish update", + children: frozenIsDraft ? "Create" : "Publish update", loading: frozenSubmitting, - disabled: frozenSubmitting, + disabled: frozenSubmitting || isVersionExhausted, }} /> diff --git a/apps/hash-frontend/src/pages/shared/entity-type-page/shared/entity-type-header.tsx b/apps/hash-frontend/src/pages/shared/entity-type-page/shared/entity-type-header.tsx index 39e9598e13a..b21a7d3403e 100644 --- a/apps/hash-frontend/src/pages/shared/entity-type-page/shared/entity-type-header.tsx +++ b/apps/hash-frontend/src/pages/shared/entity-type-page/shared/entity-type-header.tsx @@ -5,7 +5,6 @@ import { Controller } from "react-hook-form"; import { extractBaseUrl, extractVersion, - makeOntologyTypeVersion, versionedUrlFromComponents, } from "@blockprotocol/type-system"; import { @@ -65,7 +64,7 @@ export const EntityTypeHeader = ({ const latestVersionUrl = versionedUrlFromComponents( extractBaseUrl(entityTypeSchema.$id), - latestVersion ?? makeOntologyTypeVersion({ major: 0 }), + latestVersion ?? currentVersion, ); const { control } = useEntityTypeFormContext(); diff --git a/apps/hash-frontend/src/pages/shared/entity-type.tsx b/apps/hash-frontend/src/pages/shared/entity-type.tsx index 8abc2befc11..10dd7bd278a 100644 --- a/apps/hash-frontend/src/pages/shared/entity-type.tsx +++ b/apps/hash-frontend/src/pages/shared/entity-type.tsx @@ -5,7 +5,6 @@ import { useEffect, useMemo, useRef, useState } from "react"; import { atLeastOne, extractVersion, - makeOntologyTypeVersion, mustHaveAtLeastOne, } from "@blockprotocol/type-system"; import { EntityOrTypeIcon } from "@hashintel/design-system"; @@ -374,9 +373,7 @@ export const EntityType = ({ } } - const currentVersion = draftEntityType - ? makeOntologyTypeVersion({ major: 0 }) - : extractVersion(entityType.schema.$id); + const currentVersion = extractVersion(entityType.schema.$id); const convertToLinkType = wrapHandleSubmit(async (data) => { if (upgradableDependencies.length && !showDependencyUpgradeModal) { @@ -543,6 +540,7 @@ export const EntityType = ({ {!isReadonly && ( draft (same major) @@ -354,7 +358,7 @@ pub struct OntologyTypeVersion { // We don't really have a way to inform specta that this type is a string so we fake the type // to be a transparent type with only a single string type #[cfg_attr(feature = "codegen", specta(type = String))] - pub major: u32, + pub major: NonZero, #[cfg_attr(feature = "codegen", specta(skip))] pub pre_release: Option, } @@ -395,7 +399,7 @@ impl FromStr for OntologyTypeVersion { }; Ok(Self { - major: u32::from_str(version).map_err(|error| { + major: NonZero::::from_str_radix(version, 10).map_err(|error| { if *error.kind() == IntErrorKind::Empty { ParseOntologyTypeVersionError::MissingVersion } else { @@ -472,7 +476,7 @@ impl ToSql for OntologyTypeVersion { if self.pre_release.is_some() { todo!("https://linear.app/hash/issue/BE-161/allow-ids-for-pre-release-type-to-be-stored-in-postgres"); } - i64::from(self.major).to_sql(ty, out) + NonZero::::from(self.major).get().to_sql(ty, out) } } @@ -482,7 +486,7 @@ impl<'a> FromSql<'a> for OntologyTypeVersion { fn from_sql(ty: &Type, raw: &'a [u8]) -> Result> { Ok(Self { - major: i64::from_sql(ty, raw)?.try_into()?, + major: u32::try_from(i64::from_sql(ty, raw)?)?.try_into()?, pre_release: None, }) } @@ -864,7 +868,7 @@ mod tests { assert_eq!( record_id.version, OntologyTypeVersion { - major: 3, + major: NonZero::new(3).expect("version should be nonzero"), pre_release: None } ); @@ -933,17 +937,17 @@ mod tests { fn ontology_version_parsing() -> Result<(), Box> { // Test published versions let v1 = OntologyTypeVersion::from_str("1")?; - assert_eq!(v1.major, 1); + assert_eq!(v1.major.get(), 1); assert_eq!(v1.pre_release, None); assert_eq!(v1.to_string(), "1"); let v42 = OntologyTypeVersion::from_str("42")?; - assert_eq!(v42.major, 42); + assert_eq!(v42.major.get(), 42); assert_eq!(v42.pre_release, None); // Test draft versions let draft = OntologyTypeVersion::from_str("2-draft.abcd1234.5")?; - assert_eq!(draft.major, 2); + assert_eq!(draft.major.get(), 2); let Some(PreRelease::Draft { lane, revision }) = draft.pre_release.as_ref() else { panic!("draft should have pre-release information"); }; @@ -954,6 +958,18 @@ mod tests { Ok(()) } + #[test] + fn ontology_version_zero() { + for version in ["0", "0-draft.lane.1"] { + let error = OntologyTypeVersion::from_str(version) + .expect_err("zero should be rejected as an ontology version"); + assert!( + matches!(error, ParseOntologyTypeVersionError::ParseVersion(_)), + "zero should fail major version parsing" + ); + } + } + #[test] fn ontology_version_roundtrip() -> Result<(), Box> { let versions = ["1", "42", "2-draft.lane1234.1", "5-draft.xyz98765.999"]; @@ -966,10 +982,22 @@ mod tests { Ok(()) } + #[cfg(feature = "postgres")] + #[test] + fn ontology_version_from_sql_zero() { + let mut buffer = BytesMut::new(); + 0_i64 + .to_sql(&Type::INT8, &mut buffer) + .expect("zero should serialize as a PostgreSQL integer"); + + OntologyTypeVersion::from_sql(&Type::INT8, &buffer) + .expect_err("zero should be rejected as an ontology version"); + } + #[test] fn ontology_version_ordering_same_major() -> Result<(), Box> { let published = OntologyTypeVersion { - major: 2, + major: NonZero::new(2).expect("version should be nonzero"), pre_release: None, }; @@ -1085,7 +1113,7 @@ mod tests { let parsed = VersionedUrl::from_str(url_str).expect("should parse draft URL"); assert_eq!(parsed.base_url.as_str(), "https://example.com/person/"); - assert_eq!(parsed.version.major, 2); + assert_eq!(parsed.version.major.get(), 2); let Some(PreRelease::Draft { lane, revision }) = parsed.version.pre_release.as_ref() else { panic!("should have pre-release draft info"); @@ -1259,7 +1287,7 @@ mod tests { let version = OntologyTypeVersion::from_str("1-draft.lane.with.dots.5") .expect("Should accept multiple dots in lane identifier"); - assert_eq!(version.major, 1); + assert_eq!(version.major.get(), 1); let Some(PreRelease::Draft { lane, revision }) = version.pre_release.as_ref() else { panic!("should have pre-release draft info"); }; @@ -1270,7 +1298,7 @@ mod tests { let version2 = OntologyTypeVersion::from_str("2-draft.v1.alpha.3.10") .expect("Should accept lane with numbers and dots"); - assert_eq!(version2.major, 2); + assert_eq!(version2.major.get(), 2); let Some(PreRelease::Draft { lane: lane2, revision: rev2, @@ -1288,7 +1316,7 @@ mod tests { let version = OntologyTypeVersion::from_str("3-draft.my-draft.2") .expect("Should accept 'draft' in lane identifier"); - assert_eq!(version.major, 3); + assert_eq!(version.major.get(), 3); let Some(PreRelease::Draft { lane, revision }) = version.pre_release.as_ref() else { panic!("should have pre-release draft info"); }; diff --git a/libs/@blockprotocol/type-system/typescript/src/native/url.ts b/libs/@blockprotocol/type-system/typescript/src/native/url.ts index caf9d9905c1..7b0b1f86639 100644 --- a/libs/@blockprotocol/type-system/typescript/src/native/url.ts +++ b/libs/@blockprotocol/type-system/typescript/src/native/url.ts @@ -10,6 +10,8 @@ import type { import type { BaseUrl, OntologyTypeVersion } from "../generated/types.js"; import type { SemVer } from "semver"; +const u32Max = 4_294_967_295; + /** * Checks if a given URL string is a valid base URL. * @@ -125,8 +127,6 @@ const toSemVer = (version: OntologyTypeVersion): SemVer => { export const validateVersionedUrl = ( url: string, ): Result => { - const U32_MAX = 4294967295; - if (url.length > 2048) { return { type: "Err", @@ -161,7 +161,23 @@ export const validateVersionedUrl = ( try { const parsedVersion = toSemVer(version as OntologyTypeVersion); - if (parsedVersion.major > U32_MAX) { + if (parsedVersion.major === 0) { + return { + type: "Err", + inner: { + reason: "InvalidVersion", + inner: [ + version, + { + reason: "ParseVersion", + inner: "number would be zero for non-zero type", + }, + ], + }, + }; + } + + if (parsedVersion.major > u32Max) { return { type: "Err", inner: { @@ -177,12 +193,12 @@ export const validateVersionedUrl = ( }; } - // Check U32_MAX for revision in draft versions + // Check u32Max for revision in draft versions if (parsedVersion.prerelease.length > 0) { // Format is ["draft", "lane", "revision"] - revision is last element const revision = parsedVersion.prerelease[parsedVersion.prerelease.length - 1]; - if (typeof revision === "number" && revision > U32_MAX) { + if (typeof revision === "number" && revision > u32Max) { return { type: "Err", inner: { @@ -345,8 +361,15 @@ export const ontologyTypeRecordIdToVersionedUrl = ( ): VersionedUrl => `${ontologyTypeRecordId.baseUrl}v/${ontologyTypeRecordId.version.toString()}`; -export const makeOntologyTypeVersion = ({ major }: { major: number }) => - `${major}` as unknown as OntologyTypeVersion; +export const makeOntologyTypeVersion = ({ major }: { major: number }) => { + if (!Number.isInteger(major) || major < 1 || major > u32Max) { + throw new Error( + `Ontology type version must be an integer between 1 and ${u32Max}, received ${major}`, + ); + } + + return `${major}` as unknown as OntologyTypeVersion; +}; export const parseOntologyTypeVersion = ( version: string, diff --git a/libs/@blockprotocol/type-system/typescript/test/url.test.ts b/libs/@blockprotocol/type-system/typescript/test/url.test.ts index ef49fae1cfb..c4743b4adf1 100644 --- a/libs/@blockprotocol/type-system/typescript/test/url.test.ts +++ b/libs/@blockprotocol/type-system/typescript/test/url.test.ts @@ -4,6 +4,7 @@ import { compareOntologyTypeVersions, extractBaseUrl, extractVersion, + makeOntologyTypeVersion, validateBaseUrl, validateVersionedUrl, } from "../src/main.js"; @@ -14,6 +15,8 @@ import type { VersionedUrl, } from "../src/main.js"; +const u32Max = 4_294_967_295; + const invalidBaseUrlCases: string[] = [ "http://example.com", "\\example\\..\\demo/.\\/", @@ -55,9 +58,9 @@ const invalidVersionedUrlCases: string[] = [ describe("validateVersionedUrl", () => { test.each([ - ["http://example.com/v/0"], ["http://example.com/v/1"], ["http://example.com/v/20"], + [`http://example.com/v/${u32Max}`], [`http://exampl${"e".repeat(2027)}.com/v/1`], ])("`validateVersionedUrl(%s)` succeeds", (input) => { expect(validateVersionedUrl(input)).toEqual({ type: "Ok", inner: input }); @@ -69,10 +72,50 @@ describe("validateVersionedUrl", () => { expect(validateVersionedUrl(input)).toMatchSnapshot(); }, ); + + test.each(["0", "0-draft.lane.1"])( + "rejects zero major version %s", + (version) => { + expect(validateVersionedUrl(`http://example.com/v/${version}`)).toEqual({ + type: "Err", + inner: { + reason: "InvalidVersion", + inner: [ + version, + { + reason: "ParseVersion", + inner: "number would be zero for non-zero type", + }, + ], + }, + }); + }, + ); + + test("rejects a major version above u32::MAX", () => { + expect( + validateVersionedUrl(`http://example.com/v/${u32Max + 1}`).type, + ).toBe("Err"); + }); +}); + +describe("makeOntologyTypeVersion", () => { + test.each([1, 20, u32Max])("accepts major version %s", (major) => { + expect(makeOntologyTypeVersion({ major })).toBe(`${major}`); + }); + + test.each([0, -1, 1.5, Number.NaN, Number.POSITIVE_INFINITY, u32Max + 1])( + "rejects invalid major version %s", + (major) => { + expect(() => makeOntologyTypeVersion({ major })).toThrow( + `Ontology type version must be an integer between 1 and ${u32Max}`, + ); + }, + ); }); const extractBaseUrlCases: [VersionedUrl, BaseUrl][] = [ - ["http://example.com/v/0" as VersionedUrl, "http://example.com/" as BaseUrl], + ["http://example.com/v/1" as VersionedUrl, "http://example.com/" as BaseUrl], [ "http://example.com/sandwich/v/1" as VersionedUrl, "http://example.com/sandwich/" as BaseUrl, @@ -97,7 +140,7 @@ describe("extractBaseUrl", () => { }); const extractVersionCases: [VersionedUrl, string][] = [ - ["http://example.com/v/0" as VersionedUrl, "0"], + ["http://example.com/v/1" as VersionedUrl, "1"], ["http://example.com/sandwich/v/1" as VersionedUrl, "1"], ["file://localhost/documents/myfolder/v/10" as VersionedUrl, "10"], ["ftp://rms@example.com/foo/v/5" as VersionedUrl, "5"], diff --git a/libs/@local/graph/api/src/rest/legacy/data_type.rs b/libs/@local/graph/api/src/rest/legacy/data_type.rs index 57f4263c27a..ae4d4eba72a 100644 --- a/libs/@local/graph/api/src/rest/legacy/data_type.rs +++ b/libs/@local/graph/api/src/rest/legacy/data_type.rs @@ -562,7 +562,17 @@ where conversions, }) = body; - type_to_update.version.major += 1; + type_to_update.version.major = + type_to_update.version.major.checked_add(1).ok_or_else(|| { + status_to_response(Status::<()>::new( + hash_status::StatusCode::InvalidArgument, + Some(format!( + "Type version cannot be incremented beyond {}", + u32::MAX + )), + vec![], + )) + })?; let data_type = patch_id_and_parse(&type_to_update, schema).map_err(report_to_response)?; @@ -625,7 +635,17 @@ where provenance, conversions, }| { - type_to_update.version.major += 1; + type_to_update.version.major = + type_to_update.version.major.checked_add(1).ok_or_else(|| { + status_to_response(Status::<()>::new( + hash_status::StatusCode::InvalidArgument, + Some(format!( + "Type version cannot be incremented beyond {}", + u32::MAX + )), + vec![], + )) + })?; Ok(UpdateDataTypesParams { schema: patch_id_and_parse(&type_to_update, schema) diff --git a/libs/@local/graph/api/src/rest/legacy/entity_type.rs b/libs/@local/graph/api/src/rest/legacy/entity_type.rs index 73bf54bc322..28149a7a5f6 100644 --- a/libs/@local/graph/api/src/rest/legacy/entity_type.rs +++ b/libs/@local/graph/api/src/rest/legacy/entity_type.rs @@ -833,7 +833,17 @@ where provenance, }) = body; - type_to_update.version.major += 1; + type_to_update.version.major = + type_to_update.version.major.checked_add(1).ok_or_else(|| { + status_to_response(Status::new( + hash_status::StatusCode::InvalidArgument, + Some(format!( + "Type version cannot be incremented beyond {}", + u32::MAX + )), + vec![], + )) + })?; let entity_type = patch_id_and_parse(&type_to_update, schema).map_err(report_to_response)?; @@ -894,7 +904,17 @@ where mut type_to_update, provenance, }| { - type_to_update.version.major += 1; + type_to_update.version.major = + type_to_update.version.major.checked_add(1).ok_or_else(|| { + status_to_response(Status::new( + hash_status::StatusCode::InvalidArgument, + Some(format!( + "Type version cannot be incremented beyond {}", + u32::MAX + )), + vec![], + )) + })?; Ok(UpdateEntityTypesParams { schema: patch_id_and_parse(&type_to_update, schema) diff --git a/libs/@local/graph/api/src/rest/legacy/property_type.rs b/libs/@local/graph/api/src/rest/legacy/property_type.rs index 74bf6cbc03e..beaa9dce4cf 100644 --- a/libs/@local/graph/api/src/rest/legacy/property_type.rs +++ b/libs/@local/graph/api/src/rest/legacy/property_type.rs @@ -494,7 +494,17 @@ where provenance, }) = body; - type_to_update.version.major += 1; + type_to_update.version.major = + type_to_update.version.major.checked_add(1).ok_or_else(|| { + status_to_response(Status::<()>::new( + hash_status::StatusCode::InvalidArgument, + Some(format!( + "Type version cannot be incremented beyond {}", + u32::MAX + )), + vec![], + )) + })?; let property_type = patch_id_and_parse(&type_to_update, schema).map_err(report_to_response)?; @@ -555,7 +565,17 @@ where mut type_to_update, provenance, }| { - type_to_update.version.major += 1; + type_to_update.version.major = + type_to_update.version.major.checked_add(1).ok_or_else(|| { + status_to_response(Status::<()>::new( + hash_status::StatusCode::InvalidArgument, + Some(format!( + "Type version cannot be incremented beyond {}", + u32::MAX + )), + vec![], + )) + })?; Ok(UpdatePropertyTypesParams { schema: patch_id_and_parse(&type_to_update, schema) diff --git a/libs/@local/graph/api/src/rest/legacy/utoipa_typedef/subgraph/edges.rs b/libs/@local/graph/api/src/rest/legacy/utoipa_typedef/subgraph/edges.rs index 2f84447c4c5..d8e8ecdbf8d 100644 --- a/libs/@local/graph/api/src/rest/legacy/utoipa_typedef/subgraph/edges.rs +++ b/libs/@local/graph/api/src/rest/legacy/utoipa_typedef/subgraph/edges.rs @@ -240,6 +240,8 @@ impl ToSchema<'_> for Edges { #[cfg(test)] mod tests { + use core::num::NonZero; + use hash_graph_store::subgraph::{ edges::{EdgeDirection, KnowledgeGraphEdgeKind, SharedEdgeKind}, identifier::{EntityIdWithInterval, EntityTypeVertexId, EntityVertexId}, @@ -295,7 +297,7 @@ mod tests { base_id: BaseUrl::new("https://example.com/".to_owned()) .expect("should be valid URL"), revision_id: OntologyTypeVersion { - major: 0, + major: NonZero::::MIN, pre_release: None, }, }, diff --git a/libs/@local/graph/authorization/rust/src/policies/cedar/expression_tree.rs b/libs/@local/graph/authorization/rust/src/policies/cedar/expression_tree.rs index 923d66669d5..6abdc6b183d 100644 --- a/libs/@local/graph/authorization/rust/src/policies/cedar/expression_tree.rs +++ b/libs/@local/graph/authorization/rust/src/policies/cedar/expression_tree.rs @@ -1,5 +1,5 @@ use alloc::{borrow::Cow, sync::Arc}; -use core::{error::Error, str::FromStr as _}; +use core::{error::Error, num::NonZero, str::FromStr as _}; use cedar_policy_core::ast; use error_stack::{Report, ResultExt as _, TryReportTupleExt as _}; @@ -374,6 +374,7 @@ impl PolicyExpressionTree { } (AttributeType::OntologyTypeVersion, ast::ExprKind::Lit(ast::Literal::Long(long))) => { u32::try_from(*long) + .and_then(NonZero::try_from) .change_context(ParseBinaryExpressionError::Right) .map(|major| { Self::OntologyTypeVersion(OntologyTypeVersion { diff --git a/libs/@local/graph/authorization/rust/src/policies/resource/data_type.rs b/libs/@local/graph/authorization/rust/src/policies/resource/data_type.rs index 46769b59898..f044371090c 100644 --- a/libs/@local/graph/authorization/rust/src/policies/resource/data_type.rs +++ b/libs/@local/graph/authorization/rust/src/policies/resource/data_type.rs @@ -1,5 +1,5 @@ use alloc::{borrow::Cow, sync::Arc}; -use core::{error::Error, fmt, iter, ptr, str::FromStr as _}; +use core::{error::Error, fmt, iter, num::NonZeroI64, ptr, str::FromStr as _}; use std::{collections::HashSet, sync::LazyLock}; use cedar_policy_core::ast; @@ -89,9 +89,9 @@ impl DataTypeResource<'_> { ( SmolStr::new_static("version"), ast::PartialValue::Value(ast::Value::new( - ast::ValueKind::Lit(ast::Literal::Long(ast::Integer::from( - self.id.as_url().version.major, - ))), + ast::ValueKind::Lit(ast::Literal::Long( + NonZeroI64::from(self.id.as_url().version.major).get(), + )), None, )), ), diff --git a/libs/@local/graph/authorization/rust/src/policies/resource/entity_type.rs b/libs/@local/graph/authorization/rust/src/policies/resource/entity_type.rs index 32e4f3dbd64..e1381966058 100644 --- a/libs/@local/graph/authorization/rust/src/policies/resource/entity_type.rs +++ b/libs/@local/graph/authorization/rust/src/policies/resource/entity_type.rs @@ -1,5 +1,5 @@ use alloc::{borrow::Cow, sync::Arc}; -use core::{error::Error, fmt, iter, ptr, str::FromStr as _}; +use core::{error::Error, fmt, iter, num::NonZeroI64, ptr, str::FromStr as _}; use std::{collections::HashSet, sync::LazyLock}; use cedar_policy_core::ast; @@ -89,9 +89,9 @@ impl EntityTypeResource<'_> { ( SmolStr::new_static("version"), ast::PartialValue::Value(ast::Value::new( - ast::ValueKind::Lit(ast::Literal::Long(ast::Integer::from( - self.id.as_url().version.major, - ))), + ast::ValueKind::Lit(ast::Literal::Long( + NonZeroI64::from(self.id.as_url().version.major).get(), + )), None, )), ), diff --git a/libs/@local/graph/authorization/rust/src/policies/resource/property_type.rs b/libs/@local/graph/authorization/rust/src/policies/resource/property_type.rs index 830bb8391f6..bd61a5e7cbc 100644 --- a/libs/@local/graph/authorization/rust/src/policies/resource/property_type.rs +++ b/libs/@local/graph/authorization/rust/src/policies/resource/property_type.rs @@ -1,5 +1,5 @@ use alloc::{borrow::Cow, sync::Arc}; -use core::{error::Error, fmt, iter, ptr, str::FromStr as _}; +use core::{error::Error, fmt, iter, num::NonZeroI64, ptr, str::FromStr as _}; use std::{collections::HashSet, sync::LazyLock}; use cedar_policy_core::ast; @@ -89,9 +89,9 @@ impl PropertyTypeResource<'_> { ( SmolStr::new_static("version"), ast::PartialValue::Value(ast::Value::new( - ast::ValueKind::Lit(ast::Literal::Long(ast::Integer::from( - self.id.as_url().version.major, - ))), + ast::ValueKind::Lit(ast::Literal::Long( + NonZeroI64::from(self.id.as_url().version.major).get(), + )), None, )), ), diff --git a/libs/@local/graph/postgres-store/src/snapshot/ontology/data_type/channel.rs b/libs/@local/graph/postgres-store/src/snapshot/ontology/data_type/channel.rs index c7d27006565..db5b17c6d6d 100644 --- a/libs/@local/graph/postgres-store/src/snapshot/ontology/data_type/channel.rs +++ b/libs/@local/graph/postgres-store/src/snapshot/ontology/data_type/channel.rs @@ -12,12 +12,9 @@ use futures::{ }; use type_system::{ Valid, Validator as _, - ontology::{ - data_type::{ - ClosedDataType, DataTypeUuid, - schema::{DataTypeValidator, ValueLabel}, - }, - id::{OntologyTypeVersion, VersionedUrl}, + ontology::data_type::{ + ClosedDataType, DataTypeUuid, + schema::{DataTypeValidator, ValueLabel}, }, }; @@ -85,13 +82,7 @@ impl Sink for DataTypeSender { // An empty schema is inserted initially. This will be replaced later by the closed // schema. closed_schema: Valid::new_unchecked(ClosedDataType { - id: VersionedUrl { - base_url: schema.id.base_url.clone(), - version: OntologyTypeVersion { - major: 0, - pre_release: None, - }, - }, + id: schema.id.clone(), title: String::new(), title_plural: None, icon: None, diff --git a/libs/@local/graph/postgres-store/src/snapshot/ontology/entity_type/channel.rs b/libs/@local/graph/postgres-store/src/snapshot/ontology/entity_type/channel.rs index 36568d48053..9ffb5e8ae25 100644 --- a/libs/@local/graph/postgres-store/src/snapshot/ontology/entity_type/channel.rs +++ b/libs/@local/graph/postgres-store/src/snapshot/ontology/entity_type/channel.rs @@ -13,12 +13,9 @@ use futures::{ }; use type_system::{ Valid, Validator as _, - ontology::{ - entity_type::{ - ClosedEntityType, EntityTypeUuid, - schema::{EntityConstraints, EntityTypeValidator, InverseEntityTypeMetadata}, - }, - id::{OntologyTypeVersion, VersionedUrl}, + ontology::entity_type::{ + ClosedEntityType, EntityTypeUuid, + schema::{EntityConstraints, EntityTypeValidator, InverseEntityTypeMetadata}, }, }; @@ -84,13 +81,7 @@ impl Sink for EntityTypeSender { // An empty schema is inserted initially. This will be replaced later by the closed // schema. closed_schema: Valid::new_unchecked(ClosedEntityType { - id: VersionedUrl { - base_url: schema.id.base_url.clone(), - version: OntologyTypeVersion { - major: 0, - pre_release: None, - }, - }, + id: schema.id.clone(), title: String::new(), title_plural: None, description: String::new(), diff --git a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs index b2550bfb47c..dc84a7ca109 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs @@ -3505,13 +3505,18 @@ where url: &VersionedUrl, provenance: &OntologyEditionProvenance, ) -> Result<(OntologyTypeUuid, WebId, OntologyTemporalMetadata), Report> { - let previous_version = - OntologyTypeVersion { - major: url.version.major.checked_sub(1).ok_or(UpdateError).attach( - "The version of the data type is already at the lowest possible value", - )?, - pre_release: None, - }; + let previous_version = OntologyTypeVersion { + major: url + .version + .major + .get() + .checked_sub(1) + .and_then(NonZero::new) + .ok_or(OntologyVersionDoesNotExist) + .change_context(UpdateError) + .attach("The version of the data type is already at the lowest possible value")?, + pre_release: None, + }; let Some(web_id) = self .as_client() .query_opt( diff --git a/libs/@local/graph/postgres-store/src/store/postgres/ontology/data_type.rs b/libs/@local/graph/postgres-store/src/store/postgres/ontology/data_type.rs index df9e240a23d..6b4d48de7ad 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/ontology/data_type.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/ontology/data_type.rs @@ -1,5 +1,5 @@ use alloc::{borrow::Cow, sync::Arc}; -use core::mem; +use core::{mem, num::NonZero}; use std::collections::{HashMap, HashSet}; use error_stack::{Report, ResultExt as _}; @@ -52,7 +52,7 @@ use type_system::{ }; use crate::store::{ - error::DeletionError, + error::{DeletionError, OntologyVersionDoesNotExist}, postgres::{ AsClient, GenericClientIter as _, PostgresStore, TransactionState, TraversalContext, crud::{QueryIndices, QueryRecordDecode, TypedRow}, @@ -954,8 +954,11 @@ where .id .version .major + .get() .checked_sub(1) - .ok_or(UpdateError) + .and_then(NonZero::new) + .ok_or(OntologyVersionDoesNotExist) + .change_context(UpdateError) .attach( "The version of the data type is already at the lowest possible value", )?, diff --git a/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs b/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs index b82865517e1..dba803b0630 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs @@ -1,5 +1,5 @@ use alloc::{borrow::Cow, collections::BTreeSet, sync::Arc}; -use core::iter; +use core::{iter, num::NonZero}; use std::collections::{HashMap, HashSet}; use error_stack::{Report, ResultExt as _}; @@ -69,7 +69,7 @@ use type_system::{ }; use crate::store::{ - error::DeletionError, + error::{DeletionError, OntologyVersionDoesNotExist}, postgres::{ AsClient, GenericClientIter as _, PostgresStore, ResponseCountMap, TransactionState, TraversalContext, @@ -1646,8 +1646,11 @@ where .id .version .major + .get() .checked_sub(1) - .ok_or(UpdateError) + .and_then(NonZero::new) + .ok_or(OntologyVersionDoesNotExist) + .change_context(UpdateError) .attach( "The version of the entity type is already at the lowest possible \ value", diff --git a/libs/@local/graph/postgres-store/src/store/postgres/ontology/property_type.rs b/libs/@local/graph/postgres-store/src/store/postgres/ontology/property_type.rs index 3e5259e3ea9..7afda239d25 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/ontology/property_type.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/ontology/property_type.rs @@ -1,4 +1,5 @@ use alloc::borrow::Cow; +use core::num::NonZero; use std::collections::{HashMap, HashSet}; use error_stack::{Report, ResultExt as _}; @@ -49,7 +50,7 @@ use type_system::{ }; use crate::store::{ - error::DeletionError, + error::{DeletionError, OntologyVersionDoesNotExist}, postgres::{ AsClient, GenericClientIter as _, PostgresStore, TransactionState, TraversalContext, crud::{QueryIndices, QueryRecordDecode, TypedRow}, @@ -869,8 +870,11 @@ where .id .version .major + .get() .checked_sub(1) - .ok_or(UpdateError) + .and_then(NonZero::new) + .ok_or(OntologyVersionDoesNotExist) + .change_context(UpdateError) .attach( "The version of the property type is already at the lowest possible \ value", diff --git a/libs/@local/graph/postgres-store/src/store/postgres/query/compile/tests.rs b/libs/@local/graph/postgres-store/src/store/postgres/query/compile/tests.rs index 03fd41fac23..0fef207b9a3 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/query/compile/tests.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/query/compile/tests.rs @@ -1,5 +1,5 @@ use alloc::borrow::Cow; -use core::str::FromStr as _; +use core::{num::NonZero, str::FromStr as _}; use hash_codec::numeric::Real; use hash_graph_store::{ @@ -2066,7 +2066,7 @@ mod predefined { ) .expect("invalid base url"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; diff --git a/libs/@local/graph/postgres-store/tests/deletion/main.rs b/libs/@local/graph/postgres-store/tests/deletion/main.rs index f7921bbfc02..d559808748e 100644 --- a/libs/@local/graph/postgres-store/tests/deletion/main.rs +++ b/libs/@local/graph/postgres-store/tests/deletion/main.rs @@ -11,6 +11,7 @@ mod links; mod purge; mod validation; +use core::num::NonZero; use std::collections::{HashMap, HashSet}; use error_stack::{Report, ResultExt as _}; @@ -262,7 +263,7 @@ pub(crate) fn person_type_id() -> VersionedUrl { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, } @@ -275,7 +276,7 @@ pub(crate) fn friend_of_type_id() -> VersionedUrl { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, } diff --git a/libs/@local/graph/store/rust/src/filter/mod.rs b/libs/@local/graph/store/rust/src/filter/mod.rs index c14f047bf62..a316bd65e9b 100644 --- a/libs/@local/graph/store/rust/src/filter/mod.rs +++ b/libs/@local/graph/store/rust/src/filter/mod.rs @@ -1523,6 +1523,8 @@ mod tests { reason = "It's fine to error on unused arms in tests" )] + use core::num::NonZero; + use hash_graph_types::ontology::DataTypeLookup; use serde_json::json; use type_system::{ @@ -1596,7 +1598,7 @@ mod tests { ) .expect("invalid base url"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; diff --git a/tests/graph/integration/postgres/clustering.rs b/tests/graph/integration/postgres/clustering.rs index 2082e8a137c..bbbd30fe5eb 100644 --- a/tests/graph/integration/postgres/clustering.rs +++ b/tests/graph/integration/postgres/clustering.rs @@ -63,7 +63,7 @@ fn person_entity_type_id() -> VersionedUrl { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, } diff --git a/tests/graph/integration/postgres/drafts.rs b/tests/graph/integration/postgres/drafts.rs index 8f078ae91fd..b2b5e6cb415 100644 --- a/tests/graph/integration/postgres/drafts.rs +++ b/tests/graph/integration/postgres/drafts.rs @@ -1,3 +1,4 @@ +use core::num::NonZero; use std::collections::HashSet; use hash_graph_store::entity::{CreateEntityParams, EntityStore as _, PatchEntityParams}; @@ -56,7 +57,7 @@ fn person_entity_type_id() -> VersionedUrl { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, } diff --git a/tests/graph/integration/postgres/email_filter_protection.rs b/tests/graph/integration/postgres/email_filter_protection.rs index 8e4249ca934..a9a14548c00 100644 --- a/tests/graph/integration/postgres/email_filter_protection.rs +++ b/tests/graph/integration/postgres/email_filter_protection.rs @@ -9,6 +9,7 @@ //! The tests below verify each case from the truth tables in the protection module. use alloc::borrow::Cow; +use core::num::NonZero; use std::collections::HashSet; use hash_graph_postgres_store::store::PostgresStoreSettings; @@ -264,7 +265,7 @@ impl DatabaseApi<'_> { entity_type_ids: HashSet::from([VersionedUrl { base_url: BaseUrl::new(USER_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }]), @@ -299,7 +300,7 @@ impl DatabaseApi<'_> { entity_type_ids: HashSet::from([VersionedUrl { base_url: BaseUrl::new(INVITATION_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }]), @@ -337,14 +338,14 @@ impl DatabaseApi<'_> { VersionedUrl { base_url: BaseUrl::new(USER_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }, VersionedUrl { base_url: BaseUrl::new(INVITATION_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }, @@ -1476,7 +1477,7 @@ impl DatabaseApi<'_> { entity_type_ids: HashSet::from([VersionedUrl { base_url: BaseUrl::new(USER_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }]), @@ -1516,7 +1517,7 @@ impl DatabaseApi<'_> { entity_type_ids: HashSet::from([VersionedUrl { base_url: BaseUrl::new(INVITATION_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }]), @@ -2120,7 +2121,7 @@ impl DatabaseApi<'_> { entity_type_ids: HashSet::from([VersionedUrl { base_url: BaseUrl::new(USER_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }]), @@ -2160,7 +2161,7 @@ impl DatabaseApi<'_> { entity_type_ids: HashSet::from([VersionedUrl { base_url: BaseUrl::new(SECRET_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }]), @@ -2568,7 +2569,7 @@ impl DatabaseApi<'_> { entity_type_ids: HashSet::from([VersionedUrl { base_url: BaseUrl::new(USER_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }]), @@ -3021,7 +3022,7 @@ async fn subgraph_traversal_masks_linked_user_email() { let org_type_id = VersionedUrl { base_url: BaseUrl::new(ORGANIZATION_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; @@ -3029,7 +3030,7 @@ async fn subgraph_traversal_masks_linked_user_email() { let user_type_id = VersionedUrl { base_url: BaseUrl::new(USER_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; @@ -3037,7 +3038,7 @@ async fn subgraph_traversal_masks_linked_user_email() { let link_type_id = VersionedUrl { base_url: BaseUrl::new(HAS_MEMBER_LINK_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; diff --git a/tests/graph/integration/postgres/entity.rs b/tests/graph/integration/postgres/entity.rs index 4c9793da77a..32bd6e43490 100644 --- a/tests/graph/integration/postgres/entity.rs +++ b/tests/graph/integration/postgres/entity.rs @@ -1,3 +1,4 @@ +use core::num::NonZero; use std::collections::HashSet; use hash_graph_authorization::policies::{Effect, action::ActionName}; @@ -72,7 +73,7 @@ async fn insert() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }]), @@ -146,7 +147,7 @@ async fn query() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }]), @@ -218,7 +219,7 @@ async fn public_actor_reads_only_publicly_permitted_entities() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }]), @@ -352,7 +353,7 @@ async fn update() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }]), diff --git a/tests/graph/integration/postgres/links.rs b/tests/graph/integration/postgres/links.rs index ccd084ed9a6..4ab4b5256e5 100644 --- a/tests/graph/integration/postgres/links.rs +++ b/tests/graph/integration/postgres/links.rs @@ -1,4 +1,5 @@ use alloc::borrow::Cow; +use core::num::NonZero; use std::collections::HashSet; use hash_graph_store::{ @@ -65,7 +66,7 @@ async fn insert() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; @@ -126,7 +127,7 @@ async fn insert() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; @@ -299,7 +300,7 @@ async fn get_entity_links() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; @@ -310,7 +311,7 @@ async fn get_entity_links() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; @@ -321,7 +322,7 @@ async fn get_entity_links() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; @@ -567,7 +568,7 @@ async fn remove_link() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; @@ -578,7 +579,7 @@ async fn remove_link() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; diff --git a/tests/graph/integration/postgres/read_only.rs b/tests/graph/integration/postgres/read_only.rs index 04b7cbaf04f..baa4ad2b5bb 100644 --- a/tests/graph/integration/postgres/read_only.rs +++ b/tests/graph/integration/postgres/read_only.rs @@ -1,4 +1,4 @@ -use core::iter::once; +use core::{iter::once, num::NonZero}; use std::collections::{HashMap, HashSet}; use error_stack::Report; @@ -143,7 +143,7 @@ async fn read_only_modification_matrix() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; diff --git a/tests/graph/integration/postgres/semantic_search.rs b/tests/graph/integration/postgres/semantic_search.rs index 3dff8474f33..5d1ba6a6280 100644 --- a/tests/graph/integration/postgres/semantic_search.rs +++ b/tests/graph/integration/postgres/semantic_search.rs @@ -6,6 +6,7 @@ //! candidate reads, cross-branch deduplication, the exact rerank, and the rank-preserving //! hydration — against ground truth. +use core::num::NonZero; use std::collections::HashSet; use hash_graph_authorization::policies::{Effect, action::ActionName}; @@ -69,7 +70,7 @@ fn person_entity_type_id() -> VersionedUrl { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, } diff --git a/tests/graph/integration/postgres/sorting.rs b/tests/graph/integration/postgres/sorting.rs index 8fa455d6fb3..88058d0d2cc 100644 --- a/tests/graph/integration/postgres/sorting.rs +++ b/tests/graph/integration/postgres/sorting.rs @@ -1,4 +1,5 @@ use alloc::borrow::Cow; +use core::num::NonZero; use std::collections::HashSet; use hash_graph_store::{ @@ -160,7 +161,7 @@ async fn insert(database: &mut DatabaseTestWrapper) -> DatabaseApi<'_> { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; @@ -170,7 +171,7 @@ async fn insert(database: &mut DatabaseTestWrapper) -> DatabaseApi<'_> { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; diff --git a/tests/graph/integration/postgres/table.rs b/tests/graph/integration/postgres/table.rs index a49a2632dfb..5b4f31ffd64 100644 --- a/tests/graph/integration/postgres/table.rs +++ b/tests/graph/integration/postgres/table.rs @@ -1,3 +1,4 @@ +use core::num::NonZero; use std::collections::{HashMap, HashSet}; use hash_graph_authorization::policies::{ @@ -38,7 +39,7 @@ fn person_entity_type() -> VersionedUrl { ) .expect("the URL should be a valid base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, } @@ -51,7 +52,7 @@ fn page_entity_type() -> VersionedUrl { ) .expect("the URL should be a valid base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, } @@ -144,7 +145,7 @@ async fn link_rows_carry_their_endpoints() { ) .expect("the URL should be a valid base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; @@ -325,7 +326,7 @@ async fn label_sort_pages_alphabetically() { ) .expect("the URL should be a valid base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; @@ -614,7 +615,7 @@ async fn conversions_convert_row_property_values() { base_url: BaseUrl::new("http://localhost:3000/@alice/types/entity-type/line/".to_owned()) .expect("the URL should be a valid base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; @@ -973,7 +974,7 @@ async fn link_endpoints_hide_entities_the_actor_cannot_view() { ) .expect("the URL should be a valid base URL"), version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }; diff --git a/tests/graph/test-data/rust/src/seeding/producer/data_type.rs b/tests/graph/test-data/rust/src/seeding/producer/data_type.rs index 201fb1c4e40..a736c411fb0 100644 --- a/tests/graph/test-data/rust/src/seeding/producer/data_type.rs +++ b/tests/graph/test-data/rust/src/seeding/producer/data_type.rs @@ -1,5 +1,5 @@ use alloc::collections::BTreeSet; -use core::error::Error; +use core::{error::Error, num::NonZero}; use std::collections::HashMap; use error_stack::{Report, ResultExt as _, TryReportTupleExt as _}; @@ -216,7 +216,7 @@ impl Producer for DataTypePr slug_from_title(&title) ))?, version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }, diff --git a/tests/graph/test-data/rust/src/seeding/producer/entity_type.rs b/tests/graph/test-data/rust/src/seeding/producer/entity_type.rs index f4a2f05fca7..2b4d919c770 100644 --- a/tests/graph/test-data/rust/src/seeding/producer/entity_type.rs +++ b/tests/graph/test-data/rust/src/seeding/producer/entity_type.rs @@ -1,4 +1,4 @@ -use core::error::Error; +use core::{error::Error, num::NonZero}; use std::collections::{HashMap, HashSet}; use error_stack::{Report, ResultExt as _, TryReportTupleExt as _}; @@ -290,7 +290,7 @@ impl< slug_from_title(&title) ))?, version: OntologyTypeVersion { - major: 1, + major: NonZero::::MIN, pre_release: None, }, }, diff --git a/tests/graph/test-data/rust/src/seeding/producer/property_type.rs b/tests/graph/test-data/rust/src/seeding/producer/property_type.rs index 897d1b452c8..81a4759ff98 100644 --- a/tests/graph/test-data/rust/src/seeding/producer/property_type.rs +++ b/tests/graph/test-data/rust/src/seeding/producer/property_type.rs @@ -1,4 +1,4 @@ -use core::error::Error; +use core::{error::Error, num::NonZero}; use error_stack::{Report, ResultExt as _, TryReportTupleExt as _}; use hash_graph_store::{property_type::CreatePropertyTypeParams, query::ConflictBehavior}; @@ -209,7 +209,7 @@ impl Producer::MIN, pre_release: None, }, }, From 5fc062412d28afbe37cc154ac570f67e3c85833c Mon Sep 17 00:00:00 2001 From: Fedor Sakharov Date: Fri, 2 Oct 2026 13:45:19 +0200 Subject: [PATCH 2/6] Test ontology version bounds and update overflow --- .../type-system/typescript/test/url.test.ts | 16 + .../src/policies/cedar/expression_tree.rs | 45 ++ tests/graph/http/tests/friendship.http | 390 ++++++++++++++++++ 3 files changed, 451 insertions(+) diff --git a/libs/@blockprotocol/type-system/typescript/test/url.test.ts b/libs/@blockprotocol/type-system/typescript/test/url.test.ts index c4743b4adf1..4594a64d5dd 100644 --- a/libs/@blockprotocol/type-system/typescript/test/url.test.ts +++ b/libs/@blockprotocol/type-system/typescript/test/url.test.ts @@ -161,6 +161,7 @@ describe("Draft version support", () => { ["http://example.com/v/1-draft.abc12345.1"], ["http://example.com/v/2-draft.xyz98765.999"], ["http://example.com/person/v/5-draft.lane1234.42"], + [`http://example.com/v/1-draft.lane.${u32Max}`], ])("validateVersionedUrl(%s) with draft succeeds", (input) => { expect(validateVersionedUrl(input)).toEqual({ type: "Ok", inner: input }); }); @@ -177,6 +178,21 @@ describe("Draft version support", () => { }); }); + test("rejects a draft revision above u32::MAX", () => { + const version = `1-draft.lane.${u32Max + 1}`; + + expect(validateVersionedUrl(`http://example.com/v/${version}`)).toEqual({ + type: "Err", + inner: { + reason: "InvalidVersion", + inner: [ + version, + { reason: "ParseVersion", inner: "revision number too large" }, + ], + }, + }); + }); + describe("extractVersion with drafts", () => { test.each([ [ diff --git a/libs/@local/graph/authorization/rust/src/policies/cedar/expression_tree.rs b/libs/@local/graph/authorization/rust/src/policies/cedar/expression_tree.rs index 6abdc6b183d..9c81c3457c0 100644 --- a/libs/@local/graph/authorization/rust/src/policies/cedar/expression_tree.rs +++ b/libs/@local/graph/authorization/rust/src/policies/cedar/expression_tree.rs @@ -478,3 +478,48 @@ impl PolicyExpressionTree { .attach_with(|| Arc::clone(rhs)) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn version_expression(major: i64) -> ast::Expr { + ast::Expr::is_eq( + ast::Expr::get_attr( + ast::Expr::var(ast::Var::Resource), + SmolStr::new_static("ontology_type_version"), + ), + ast::Expr::val(major), + ) + } + + #[test] + fn from_expr_zero_version() { + let expression = ast::Expr::and(ast::Expr::val(true), version_expression(0)); + let error = PolicyExpressionTree::from_expr(&expression) + .expect_err("a policy containing version zero should be rejected"); + + assert!( + matches!(error.current_context(), ParseExpressionError::AndExpression), + "the containing policy expression should fail parsing" + ); + assert!( + error.contains::(), + "the error should retain the invalid version operand context" + ); + } + + #[test] + fn from_expr_version_bounds() { + for major in [1, u32::MAX] { + let expression = + ast::Expr::and(ast::Expr::val(true), version_expression(i64::from(major))); + let parsed = PolicyExpressionTree::from_expr(&expression) + .expect("a policy containing a valid version should parse"); + let PolicyExpressionTree::OntologyTypeVersion(version) = parsed else { + panic!("the policy should preserve its version condition"); + }; + assert_eq!(version.major.get(), major); + } + } +} diff --git a/tests/graph/http/tests/friendship.http b/tests/graph/http/tests/friendship.http index 840435ed888..773ee807006 100644 --- a/tests/graph/http/tests/friendship.http +++ b/tests/graph/http/tests/friendship.http @@ -2063,3 +2063,393 @@ X-Authenticated-User-Actor-Id: {{user_id}} }); client.global.set("person_c_entity_id", response.body.metadata.recordId.entityId); %} + +### Reject data type version overflow +PUT http://127.0.0.1:4000/data-types +Content-Type: application/json +Accept: application/json +X-Authenticated-User-Actor-Id: {{user_id}} + +{ + "typeToUpdate": "http://localhost:3000/@alice/types/data-type/meter/v/4294967295", + "schema": { + "$schema": "https://blockprotocol.org/types/modules/graph/0.3/schema/data-type", + "kind": "dataType", + "title": "Meter", + "description": "A non-negative distance in meters.", + "type": "number", + "minimum": 0 + }, + "provenance": { + "actorType": "machine", + "origin": { + "type": "api" + } + }, + "conversions": {} +} + +> {% + client.test("version overflow", function() { + client.assert(response.status === 400, "Version overflow should return HTTP 400"); + client.assert(response.body.code === "INVALID_ARGUMENT", "Version overflow should return INVALID_ARGUMENT"); + client.assert(response.body.message === "Type version cannot be incremented beyond 4294967295", "The error should identify version overflow"); + }); +%} + +### Reject bulk data type version overflow +PUT http://127.0.0.1:4000/data-types/bulk +Content-Type: application/json +Accept: application/json +X-Authenticated-User-Actor-Id: {{user_id}} + +[ + { + "typeToUpdate": "http://localhost:3000/@alice/types/data-type/meter/v/2", + "schema": { + "$schema": "https://blockprotocol.org/types/modules/graph/0.3/schema/data-type", + "kind": "dataType", + "title": "Meter", + "description": "A non-negative distance in meters.", + "type": "number", + "minimum": 0 + }, + "provenance": { + "actorType": "machine", + "origin": { + "type": "api" + } + }, + "conversions": {} + }, + { + "typeToUpdate": "http://localhost:3000/@alice/types/data-type/meter/v/4294967295", + "schema": { + "$schema": "https://blockprotocol.org/types/modules/graph/0.3/schema/data-type", + "kind": "dataType", + "title": "Meter", + "description": "A non-negative distance in meters.", + "type": "number", + "minimum": 0 + }, + "provenance": { + "actorType": "machine", + "origin": { + "type": "api" + } + }, + "conversions": {} + } +] + +> {% + client.test("version overflow", function() { + client.assert(response.status === 400, "Version overflow should return HTTP 400"); + client.assert(response.body.code === "INVALID_ARGUMENT", "Version overflow should return INVALID_ARGUMENT"); + client.assert(response.body.message === "Type version cannot be incremented beyond 4294967295", "The error should identify version overflow"); + }); +%} + +### Verify rejected bulk update did not create a data type version +POST http://127.0.0.1:4000/data-types/query +Content-Type: application/json +Accept: application/json +X-Authenticated-User-Actor-Id: {{user_id}} + +{ + "filter": { + "equal": [ + { + "path": [ + "versionedUrl" + ] + }, + { + "parameter": "http://localhost:3000/@alice/types/data-type/meter/v/3" + } + ] + }, + "temporalAxes": { + "pinned": { + "axis": "transactionTime", + "timestamp": null + }, + "variable": { + "axis": "decisionTime", + "interval": { + "start": { + "kind": "unbounded" + }, + "end": null + } + } + } +} + +> {% + client.test("bulk update is atomic", function() { + client.assert(response.status === 200, "The type query should succeed"); + client.assert(response.body.dataTypes.length === 0, "The valid item preceding the overflow should not create version 3"); + }); +%} + +### Reject property type version overflow +PUT http://127.0.0.1:4000/property-types +Content-Type: application/json +Accept: application/json +X-Authenticated-User-Actor-Id: {{user_id}} + +{ + "typeToUpdate": "http://localhost:3000/@alice/types/property-type/name/v/4294967295", + "schema": { + "$schema": "https://blockprotocol.org/types/modules/graph/0.3/schema/property-type", + "kind": "propertyType", + "title": "Name", + "description": "A person’s name.", + "oneOf": [ + { + "$ref": "{{text_data_type_id}}" + } + ] + }, + "provenance": { + "actorType": "machine", + "origin": { + "type": "api" + } + } +} + +> {% + client.test("version overflow", function() { + client.assert(response.status === 400, "Version overflow should return HTTP 400"); + client.assert(response.body.code === "INVALID_ARGUMENT", "Version overflow should return INVALID_ARGUMENT"); + client.assert(response.body.message === "Type version cannot be incremented beyond 4294967295", "The error should identify version overflow"); + }); +%} + +### Reject bulk property type version overflow +PUT http://127.0.0.1:4000/property-types/bulk +Content-Type: application/json +Accept: application/json +X-Authenticated-User-Actor-Id: {{user_id}} + +[ + { + "typeToUpdate": "http://localhost:3000/@alice/types/property-type/name/v/2", + "schema": { + "$schema": "https://blockprotocol.org/types/modules/graph/0.3/schema/property-type", + "kind": "propertyType", + "title": "Name", + "description": "A person’s name.", + "oneOf": [ + { + "$ref": "{{text_data_type_id}}" + } + ] + }, + "provenance": { + "actorType": "machine", + "origin": { + "type": "api" + } + } + }, + { + "typeToUpdate": "http://localhost:3000/@alice/types/property-type/name/v/4294967295", + "schema": { + "$schema": "https://blockprotocol.org/types/modules/graph/0.3/schema/property-type", + "kind": "propertyType", + "title": "Name", + "description": "A person’s name.", + "oneOf": [ + { + "$ref": "{{text_data_type_id}}" + } + ] + }, + "provenance": { + "actorType": "machine", + "origin": { + "type": "api" + } + } + } +] + +> {% + client.test("version overflow", function() { + client.assert(response.status === 400, "Version overflow should return HTTP 400"); + client.assert(response.body.code === "INVALID_ARGUMENT", "Version overflow should return INVALID_ARGUMENT"); + client.assert(response.body.message === "Type version cannot be incremented beyond 4294967295", "The error should identify version overflow"); + }); +%} + +### Verify rejected bulk update did not create a property type version +POST http://127.0.0.1:4000/property-types/query +Content-Type: application/json +Accept: application/json +X-Authenticated-User-Actor-Id: {{user_id}} + +{ + "filter": { + "equal": [ + { + "path": [ + "versionedUrl" + ] + }, + { + "parameter": "http://localhost:3000/@alice/types/property-type/name/v/3" + } + ] + }, + "temporalAxes": { + "pinned": { + "axis": "transactionTime", + "timestamp": null + }, + "variable": { + "axis": "decisionTime", + "interval": { + "start": { + "kind": "unbounded" + }, + "end": null + } + } + } +} + +> {% + client.test("bulk update is atomic", function() { + client.assert(response.status === 200, "The type query should succeed"); + client.assert(response.body.propertyTypes.length === 0, "The valid item preceding the overflow should not create version 3"); + }); +%} + +### Reject entity type version overflow +PUT http://127.0.0.1:4000/entity-types +Content-Type: application/json +Accept: application/json +X-Authenticated-User-Actor-Id: {{user_id}} + +{ + "typeToUpdate": "http://localhost:3000/@alice/types/entity-type/person/v/4294967295", + "schema": { + "$schema": "https://blockprotocol.org/types/modules/graph/0.3/schema/entity-type", + "kind": "entityType", + "title": "Person", + "description": "A person.", + "type": "object", + "properties": {} + }, + "provenance": { + "actorType": "machine", + "origin": { + "type": "api" + } + } +} + +> {% + client.test("version overflow", function() { + client.assert(response.status === 400, "Version overflow should return HTTP 400"); + client.assert(response.body.code === "INVALID_ARGUMENT", "Version overflow should return INVALID_ARGUMENT"); + client.assert(response.body.message === "Type version cannot be incremented beyond 4294967295", "The error should identify version overflow"); + }); +%} + +### Reject bulk entity type version overflow +PUT http://127.0.0.1:4000/entity-types/bulk +Content-Type: application/json +Accept: application/json +X-Authenticated-User-Actor-Id: {{user_id}} + +[ + { + "typeToUpdate": "http://localhost:3000/@alice/types/entity-type/person/v/2", + "schema": { + "$schema": "https://blockprotocol.org/types/modules/graph/0.3/schema/entity-type", + "kind": "entityType", + "title": "Person", + "description": "A person.", + "type": "object", + "properties": {} + }, + "provenance": { + "actorType": "machine", + "origin": { + "type": "api" + } + } + }, + { + "typeToUpdate": "http://localhost:3000/@alice/types/entity-type/person/v/4294967295", + "schema": { + "$schema": "https://blockprotocol.org/types/modules/graph/0.3/schema/entity-type", + "kind": "entityType", + "title": "Person", + "description": "A person.", + "type": "object", + "properties": {} + }, + "provenance": { + "actorType": "machine", + "origin": { + "type": "api" + } + } + } +] + +> {% + client.test("version overflow", function() { + client.assert(response.status === 400, "Version overflow should return HTTP 400"); + client.assert(response.body.code === "INVALID_ARGUMENT", "Version overflow should return INVALID_ARGUMENT"); + client.assert(response.body.message === "Type version cannot be incremented beyond 4294967295", "The error should identify version overflow"); + }); +%} + +### Verify rejected bulk update did not create an entity type version +POST http://127.0.0.1:4000/entity-types/query +Content-Type: application/json +Accept: application/json +X-Authenticated-User-Actor-Id: {{user_id}} + +{ + "filter": { + "equal": [ + { + "path": [ + "versionedUrl" + ] + }, + { + "parameter": "http://localhost:3000/@alice/types/entity-type/person/v/3" + } + ] + }, + "temporalAxes": { + "pinned": { + "axis": "transactionTime", + "timestamp": null + }, + "variable": { + "axis": "decisionTime", + "interval": { + "start": { + "kind": "unbounded" + }, + "end": null + } + } + } +} + +> {% + client.test("bulk update is atomic", function() { + client.assert(response.status === 200, "The type query should succeed"); + client.assert(response.body.entityTypes.length === 0, "The valid item preceding the overflow should not create version 3"); + }); +%} From c2d12a1a13007483f09829ab1784631c3b63f63b Mon Sep 17 00:00:00 2001 From: Fedor Sakharov Date: Fri, 2 Oct 2026 16:32:42 +0200 Subject: [PATCH 3/6] Handle invalid ontology versions in type routes --- .../[...slug-maybe-version].page.test.tsx | 63 +++++++++++++++++++ .../[...slug-maybe-version].page.tsx | 12 +++- .../[...slug-maybe-version].page.tsx | 12 +++- ...[...base64-baseurl-maybe-version].page.tsx | 14 ++++- ...ase64-baseurl-maybe-version].page.test.tsx | 46 ++++++++++++++ ...[...base64-baseurl-maybe-version].page.tsx | 14 ++++- 6 files changed, 157 insertions(+), 4 deletions(-) create mode 100644 apps/hash-frontend/src/pages/@/[shortname]/types/data-type/[...slug-maybe-version].page.test.tsx create mode 100644 apps/hash-frontend/src/pages/types/external/entity-type/[...base64-baseurl-maybe-version].page.test.tsx diff --git a/apps/hash-frontend/src/pages/@/[shortname]/types/data-type/[...slug-maybe-version].page.test.tsx b/apps/hash-frontend/src/pages/@/[shortname]/types/data-type/[...slug-maybe-version].page.test.tsx new file mode 100644 index 00000000000..aa7b87eff36 --- /dev/null +++ b/apps/hash-frontend/src/pages/@/[shortname]/types/data-type/[...slug-maybe-version].page.test.tsx @@ -0,0 +1,63 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, test, vi } from "vitest"; + +import Page from "./[...slug-maybe-version].page"; + +const router = vi.hoisted(() => ({ + query: {} as Record, + asPath: "", +})); + +vi.mock("next/router", () => ({ useRouter: () => router })); +vi.mock("../../../../../shared/layout", () => ({ + getLayoutWithSidebar: () => null, +})); +vi.mock("../../../../shared/not-found", () => ({ + NotFound: () => "not found", +})); +vi.mock("../../../../shared/data-type", () => ({ + DataType: ({ requestedVersion }: { requestedVersion: string | null }) => + requestedVersion ?? "latest", +})); +vi.mock("../../../../../shared/generate-link-parameters", () => ({ + generateLinkParameters: () => ({ href: "/" }), +})); +vi.mock("../../shared/use-route-namespace", () => ({ + useRouteNamespace: () => ({ + loading: false, + routeNamespace: { webId: "alice" }, + }), +})); +vi.mock("../shared/get-type-base-url", () => ({ + getTypeBaseUrl: () => "http://example.com/types/data-type/test/", +})); + +const renderVersion = (version?: string) => { + router.asPath = `/@alice/types/data-type/test${version === undefined ? "" : `/v/${version}`}`; + return renderToStaticMarkup(); +}; + +describe("data type route versions", () => { + test.each(["0", "4294967296", "-1", "1.5", "invalid", "1invalid"])( + "renders not found for version %s", + (version) => { + expect(renderVersion(version)).toBe("not found"); + }, + ); + + test.each(["1", "4294967295"])("renders version %s", (version) => { + expect(renderVersion(version)).toContain(version); + }); + + test.each(["1?tab=definition", "1#definition"])( + "renders a valid version with a query or fragment: %s", + (version) => { + expect(renderVersion(version)).toContain("1"); + expect(renderVersion(version)).not.toContain("not found"); + }, + ); + + test("renders the latest version when no version is requested", () => { + expect(renderVersion()).toContain("latest"); + }); +}); diff --git a/apps/hash-frontend/src/pages/@/[shortname]/types/data-type/[...slug-maybe-version].page.tsx b/apps/hash-frontend/src/pages/@/[shortname]/types/data-type/[...slug-maybe-version].page.tsx index 418b6fae392..21047500f26 100644 --- a/apps/hash-frontend/src/pages/@/[shortname]/types/data-type/[...slug-maybe-version].page.tsx +++ b/apps/hash-frontend/src/pages/@/[shortname]/types/data-type/[...slug-maybe-version].page.tsx @@ -7,11 +7,13 @@ import { componentsFromVersionedUrl, currentTimestamp, parseOntologyTypeVersion, + validateVersionedUrl, } from "@blockprotocol/type-system"; import { generateLinkParameters } from "../../../../../shared/generate-link-parameters"; import { getLayoutWithSidebar } from "../../../../../shared/layout"; import { DataType } from "../../../../shared/data-type"; +import { NotFound } from "../../../../shared/not-found"; import { useRouteNamespace } from "../../shared/use-route-namespace"; import { getTypeBaseUrl } from "../shared/get-type-base-url"; @@ -45,7 +47,7 @@ const Page: NextPageWithLayout = () => { slug, _v, requestedVersionString, - ] = router.asPath.split("/") as [ + ] = router.asPath.replace(/[?#].*$/, "").split("/") as [ "", `@${string}`, "types", @@ -109,6 +111,14 @@ const Page: NextPageWithLayout = () => { } }, [router.query.draft]); + if ( + requestedVersionString && + validateVersionedUrl(`${dataTypeBaseUrl}v/${requestedVersionString}`) + .type === "Err" + ) { + return ; + } + const requestedVersion = requestedVersionString ? parseOntologyTypeVersion(requestedVersionString) : null; diff --git a/apps/hash-frontend/src/pages/@/[shortname]/types/entity-type/[...slug-maybe-version].page.tsx b/apps/hash-frontend/src/pages/@/[shortname]/types/entity-type/[...slug-maybe-version].page.tsx index b297152f22f..c17df2347eb 100644 --- a/apps/hash-frontend/src/pages/@/[shortname]/types/entity-type/[...slug-maybe-version].page.tsx +++ b/apps/hash-frontend/src/pages/@/[shortname]/types/entity-type/[...slug-maybe-version].page.tsx @@ -7,10 +7,12 @@ import { componentsFromVersionedUrl, currentTimestamp, parseOntologyTypeVersion, + validateVersionedUrl, } from "@blockprotocol/type-system"; import { getLayoutWithSidebar } from "../../../../../shared/layout"; import { EntityType } from "../../../../shared/entity-type"; +import { NotFound } from "../../../../shared/not-found"; import { useRouteNamespace } from "../../shared/use-route-namespace"; import { getTypeBaseUrl } from "../shared/get-type-base-url"; @@ -44,7 +46,7 @@ const Page: NextPageWithLayout = () => { slug, _v, requestedVersionString, - ] = router.asPath.split("/") as [ + ] = router.asPath.replace(/[?#].*$/, "").split("/") as [ "", `@${string}`, "types", @@ -107,6 +109,14 @@ const Page: NextPageWithLayout = () => { } }, [router.query.draft]); + if ( + requestedVersionString && + validateVersionedUrl(`${entityTypeBaseUrl}v/${requestedVersionString}`) + .type === "Err" + ) { + return ; + } + const requestedVersion = requestedVersionString ? parseOntologyTypeVersion(requestedVersionString) : null; diff --git a/apps/hash-frontend/src/pages/types/external/data-type/[...base64-baseurl-maybe-version].page.tsx b/apps/hash-frontend/src/pages/types/external/data-type/[...base64-baseurl-maybe-version].page.tsx index 36ef34d5642..e6881ca910a 100644 --- a/apps/hash-frontend/src/pages/types/external/data-type/[...base64-baseurl-maybe-version].page.tsx +++ b/apps/hash-frontend/src/pages/types/external/data-type/[...base64-baseurl-maybe-version].page.tsx @@ -1,9 +1,13 @@ import { useRouter } from "next/router"; -import { parseOntologyTypeVersion } from "@blockprotocol/type-system"; +import { + parseOntologyTypeVersion, + validateVersionedUrl, +} from "@blockprotocol/type-system"; import { getLayoutWithSidebar } from "../../../../shared/layout"; import { DataType } from "../../../shared/data-type"; +import { NotFound } from "../../../shared/not-found"; import type { NextPageWithLayout } from "../../../../shared/layout"; import type { BaseUrl } from "@blockprotocol/type-system"; @@ -17,6 +21,14 @@ const Page: NextPageWithLayout = () => { const dataTypeBaseUrl = atob(base64EncodedBaseUrl) as BaseUrl; + if ( + requestedVersionString && + validateVersionedUrl(`${dataTypeBaseUrl}v/${requestedVersionString}`) + .type === "Err" + ) { + return ; + } + const requestedVersion = requestedVersionString ? parseOntologyTypeVersion(requestedVersionString) : null; diff --git a/apps/hash-frontend/src/pages/types/external/entity-type/[...base64-baseurl-maybe-version].page.test.tsx b/apps/hash-frontend/src/pages/types/external/entity-type/[...base64-baseurl-maybe-version].page.test.tsx new file mode 100644 index 00000000000..d9a1d89f350 --- /dev/null +++ b/apps/hash-frontend/src/pages/types/external/entity-type/[...base64-baseurl-maybe-version].page.test.tsx @@ -0,0 +1,46 @@ +import { renderToStaticMarkup } from "react-dom/server"; +import { describe, expect, test, vi } from "vitest"; + +import Page from "./[...base64-baseurl-maybe-version].page"; + +const router = vi.hoisted(() => ({ + query: {} as Record, + asPath: "", +})); + +vi.mock("next/router", () => ({ useRouter: () => router })); +vi.mock("../../../../shared/layout", () => ({ + getLayoutWithSidebar: () => null, +})); +vi.mock("../../../shared/not-found", () => ({ NotFound: () => "not found" })); +vi.mock("../../../shared/entity-type", () => ({ + EntityType: ({ requestedVersion }: { requestedVersion: string | null }) => + requestedVersion ?? "latest", +})); + +const renderVersion = (version?: string) => { + router.query = { + "base64-baseurl-maybe-version": [ + btoa("http://example.com/types/entity-type/test/"), + ...(version === undefined ? [] : ["v", version]), + ], + }; + return renderToStaticMarkup(); +}; + +describe("entity type route versions", () => { + test.each(["0", "4294967296", "-1", "1.5", "invalid", "1invalid"])( + "renders not found for version %s", + (version) => { + expect(renderVersion(version)).toBe("not found"); + }, + ); + + test.each(["1", "4294967295"])("renders version %s", (version) => { + expect(renderVersion(version)).toContain(version); + }); + + test("renders the latest version when no version is requested", () => { + expect(renderVersion()).toContain("latest"); + }); +}); diff --git a/apps/hash-frontend/src/pages/types/external/entity-type/[...base64-baseurl-maybe-version].page.tsx b/apps/hash-frontend/src/pages/types/external/entity-type/[...base64-baseurl-maybe-version].page.tsx index 56020eb2dcd..c816859ae47 100644 --- a/apps/hash-frontend/src/pages/types/external/entity-type/[...base64-baseurl-maybe-version].page.tsx +++ b/apps/hash-frontend/src/pages/types/external/entity-type/[...base64-baseurl-maybe-version].page.tsx @@ -1,9 +1,13 @@ import { useRouter } from "next/router"; -import { parseOntologyTypeVersion } from "@blockprotocol/type-system"; +import { + parseOntologyTypeVersion, + validateVersionedUrl, +} from "@blockprotocol/type-system"; import { getLayoutWithSidebar } from "../../../../shared/layout"; import { EntityType } from "../../../shared/entity-type"; +import { NotFound } from "../../../shared/not-found"; import type { NextPageWithLayout } from "../../../../shared/layout"; import type { BaseUrl } from "@blockprotocol/type-system"; @@ -17,6 +21,14 @@ const Page: NextPageWithLayout = () => { const entityTypeBaseUrl = atob(base64EncodedBaseUrl) as BaseUrl; + if ( + requestedVersionString && + validateVersionedUrl(`${entityTypeBaseUrl}v/${requestedVersionString}`) + .type === "Err" + ) { + return ; + } + const requestedVersion = requestedVersionString ? parseOntologyTypeVersion(requestedVersionString) : null; From 476a21eacb466f6e7d23456fff79782749591803 Mon Sep 17 00:00:00 2001 From: Fedor Sakharov Date: Mon, 5 Oct 2026 08:33:22 +0200 Subject: [PATCH 4/6] Add typed ontology major version arithmetic --- .../type-system/rust/src/ontology/id/error.rs | 8 ++ .../type-system/rust/src/ontology/id/mod.rs | 133 ++++++++++++++++-- .../graph/api/src/rest/legacy/data_type.rs | 32 ++--- .../graph/api/src/rest/legacy/entity_type.rs | 32 ++--- .../api/src/rest/legacy/property_type.rs | 32 ++--- .../graph/api/src/rest/legacy/status.rs | 43 ++++++ .../legacy/utoipa_typedef/subgraph/edges.rs | 5 +- .../src/policies/cedar/expression_tree.rs | 2 +- .../rust/src/policies/resource/data_type.rs | 8 +- .../rust/src/policies/resource/entity_type.rs | 8 +- .../src/policies/resource/property_type.rs | 8 +- .../postgres-store/src/store/postgres/mod.rs | 6 +- .../src/store/postgres/ontology/data_type.rs | 8 +- .../store/postgres/ontology/entity_type.rs | 8 +- .../store/postgres/ontology/property_type.rs | 7 +- .../src/store/postgres/query/compile/tests.rs | 6 +- .../postgres-store/tests/deletion/main.rs | 7 +- .../@local/graph/store/rust/src/filter/mod.rs | 9 +- .../graph/integration/postgres/clustering.rs | 4 +- tests/graph/integration/postgres/drafts.rs | 5 +- .../postgres/email_filter_protection.rs | 27 ++-- tests/graph/integration/postgres/entity.rs | 11 +- tests/graph/integration/postgres/links.rs | 17 ++- tests/graph/integration/postgres/read_only.rs | 6 +- .../integration/postgres/semantic_search.rs | 5 +- tests/graph/integration/postgres/sorting.rs | 7 +- tests/graph/integration/postgres/table.rs | 15 +- .../rust/src/seeding/producer/data_type.rs | 6 +- .../rust/src/seeding/producer/entity_type.rs | 6 +- .../src/seeding/producer/property_type.rs | 6 +- 30 files changed, 290 insertions(+), 187 deletions(-) diff --git a/libs/@blockprotocol/type-system/rust/src/ontology/id/error.rs b/libs/@blockprotocol/type-system/rust/src/ontology/id/error.rs index 55a52d03f33..7dd1c9e28f5 100644 --- a/libs/@blockprotocol/type-system/rust/src/ontology/id/error.rs +++ b/libs/@blockprotocol/type-system/rust/src/ontology/id/error.rs @@ -3,6 +3,14 @@ use thiserror::Error; #[cfg(target_arch = "wasm32")] use tsify::Tsify; +#[derive(Debug, Copy, Clone, PartialEq, Eq, derive_more::Display, derive_more::Error)] +pub enum OntologyTypeMajorVersionError { + #[display("Type version cannot be incremented beyond {}", u32::MAX)] + Overflow, + #[display("Type version cannot be decremented below 1")] + Underflow, +} + // TODO: Use error-stack // see https://linear.app/hash/issue/BE-160/simplify-error-handling-in-type-system-package #[cfg_attr(target_arch = "wasm32", derive(Tsify))] diff --git a/libs/@blockprotocol/type-system/rust/src/ontology/id/mod.rs b/libs/@blockprotocol/type-system/rust/src/ontology/id/mod.rs index b7e9c273065..3c9257accd4 100644 --- a/libs/@blockprotocol/type-system/rust/src/ontology/id/mod.rs +++ b/libs/@blockprotocol/type-system/rust/src/ontology/id/mod.rs @@ -35,13 +35,16 @@ use core::error::Error; use core::{ cmp, fmt, - num::{IntErrorKind, NonZero}, + num::{IntErrorKind, NonZeroU32}, str::FromStr, }; #[cfg(feature = "postgres")] use bytes::BytesMut; -pub use error::{ParseBaseUrlError, ParseDraftInfoError, ParseVersionedUrlError}; +pub use error::{ + OntologyTypeMajorVersionError, ParseBaseUrlError, ParseDraftInfoError, ParseVersionedUrlError, +}; +use error_stack::Report; #[cfg(feature = "postgres")] use postgres_types::{FromSql, IsNull, ToSql, Type}; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; @@ -230,6 +233,62 @@ impl<'a> FromSql<'a> for BaseUrl { } } +/// A major version between 1 and [`u32::MAX`]. +#[derive( + Debug, + Copy, + Clone, + Hash, + PartialEq, + Eq, + PartialOrd, + Ord, + Serialize, + Deserialize, + derive_more::Display, + derive_more::From, +)] +#[serde(transparent)] +pub struct OntologyTypeMajorVersion(NonZeroU32); + +impl OntologyTypeMajorVersion { + pub const MAX: Self = Self(NonZeroU32::MAX); + pub const MIN: Self = Self(NonZeroU32::MIN); + + #[must_use] + pub const fn new(value: u32) -> Option { + match NonZeroU32::new(value) { + Some(value) => Some(Self(value)), + None => None, + } + } + + #[must_use] + pub const fn get(self) -> u32 { + self.0.get() + } + + /// # Errors + /// + /// Returns [`OntologyTypeMajorVersionError::Overflow`] at [`Self::MAX`]. + pub fn next(self) -> Result> { + self.0 + .checked_add(1) + .map(Self) + .ok_or_else(|| Report::new(OntologyTypeMajorVersionError::Overflow)) + } + + /// # Errors + /// + /// Returns [`OntologyTypeMajorVersionError::Underflow`] at [`Self::MIN`]. + pub fn previous(self) -> Result> { + self.get() + .checked_sub(1) + .and_then(Self::new) + .ok_or_else(|| Report::new(OntologyTypeMajorVersionError::Underflow)) + } +} + /// Pre-release version information for an ontology type. /// /// Represents different pre-release stages following semantic versioning conventions. @@ -358,7 +417,7 @@ pub struct OntologyTypeVersion { // We don't really have a way to inform specta that this type is a string so we fake the type // to be a transparent type with only a single string type #[cfg_attr(feature = "codegen", specta(type = String))] - pub major: NonZero, + pub major: OntologyTypeMajorVersion, #[cfg_attr(feature = "codegen", specta(skip))] pub pre_release: Option, } @@ -399,13 +458,15 @@ impl FromStr for OntologyTypeVersion { }; Ok(Self { - major: NonZero::::from_str_radix(version, 10).map_err(|error| { - if *error.kind() == IntErrorKind::Empty { - ParseOntologyTypeVersionError::MissingVersion - } else { - ParseOntologyTypeVersionError::ParseVersion(error.to_string()) - } - })?, + major: NonZeroU32::from_str_radix(version, 10) + .map(OntologyTypeMajorVersion::from) + .map_err(|error| { + if *error.kind() == IntErrorKind::Empty { + ParseOntologyTypeVersionError::MissingVersion + } else { + ParseOntologyTypeVersionError::ParseVersion(error.to_string()) + } + })?, pre_release: draft_info .map(|draft_info| { draft_info.parse().map_err(|error| { @@ -476,7 +537,7 @@ impl ToSql for OntologyTypeVersion { if self.pre_release.is_some() { todo!("https://linear.app/hash/issue/BE-161/allow-ids-for-pre-release-type-to-be-stored-in-postgres"); } - NonZero::::from(self.major).get().to_sql(ty, out) + i64::from(self.major.get()).to_sql(ty, out) } } @@ -486,7 +547,7 @@ impl<'a> FromSql<'a> for OntologyTypeVersion { fn from_sql(ty: &Type, raw: &'a [u8]) -> Result> { Ok(Self { - major: u32::try_from(i64::from_sql(ty, raw)?)?.try_into()?, + major: NonZeroU32::try_from(u32::try_from(i64::from_sql(ty, raw)?)?)?.into(), pre_release: None, }) } @@ -868,7 +929,7 @@ mod tests { assert_eq!( record_id.version, OntologyTypeVersion { - major: NonZero::new(3).expect("version should be nonzero"), + major: OntologyTypeMajorVersion::new(3).expect("version should be nonzero"), pre_release: None } ); @@ -970,6 +1031,50 @@ mod tests { } } + #[test] + fn major_version_steps() { + for value in [1, 2, u32::MAX - 1] { + let version = OntologyTypeMajorVersion::new(value) + .expect("a positive value should create a major version"); + let next = version.next().expect("the version should have a successor"); + assert_eq!( + next.get(), + value + 1, + "the successor should increment the version" + ); + assert_eq!( + next.previous() + .expect("the successor should have a predecessor"), + version, + "the predecessor should restore the original version" + ); + } + } + + #[test] + fn major_version_boundaries() { + assert!( + OntologyTypeMajorVersion::new(0).is_none(), + "zero should be rejected" + ); + let overflow = OntologyTypeMajorVersion::MAX + .next() + .expect_err("the maximum version should have no successor"); + assert_eq!( + overflow.current_context(), + &OntologyTypeMajorVersionError::Overflow, + "incrementing the maximum should report overflow" + ); + let underflow = OntologyTypeMajorVersion::MIN + .previous() + .expect_err("the minimum version should have no predecessor"); + assert_eq!( + underflow.current_context(), + &OntologyTypeMajorVersionError::Underflow, + "decrementing the minimum should report underflow" + ); + } + #[test] fn ontology_version_roundtrip() -> Result<(), Box> { let versions = ["1", "42", "2-draft.lane1234.1", "5-draft.xyz98765.999"]; @@ -997,7 +1102,7 @@ mod tests { #[test] fn ontology_version_ordering_same_major() -> Result<(), Box> { let published = OntologyTypeVersion { - major: NonZero::new(2).expect("version should be nonzero"), + major: OntologyTypeMajorVersion::new(2).expect("version should be nonzero"), pre_release: None, }; diff --git a/libs/@local/graph/api/src/rest/legacy/data_type.rs b/libs/@local/graph/api/src/rest/legacy/data_type.rs index ae4d4eba72a..2f740f1d634 100644 --- a/libs/@local/graph/api/src/rest/legacy/data_type.rs +++ b/libs/@local/graph/api/src/rest/legacy/data_type.rs @@ -562,17 +562,11 @@ where conversions, }) = body; - type_to_update.version.major = - type_to_update.version.major.checked_add(1).ok_or_else(|| { - status_to_response(Status::<()>::new( - hash_status::StatusCode::InvalidArgument, - Some(format!( - "Type version cannot be incremented beyond {}", - u32::MAX - )), - vec![], - )) - })?; + type_to_update.version.major = type_to_update + .version + .major + .next() + .map_err(report_to_response)?; let data_type = patch_id_and_parse(&type_to_update, schema).map_err(report_to_response)?; @@ -635,17 +629,11 @@ where provenance, conversions, }| { - type_to_update.version.major = - type_to_update.version.major.checked_add(1).ok_or_else(|| { - status_to_response(Status::<()>::new( - hash_status::StatusCode::InvalidArgument, - Some(format!( - "Type version cannot be incremented beyond {}", - u32::MAX - )), - vec![], - )) - })?; + type_to_update.version.major = type_to_update + .version + .major + .next() + .map_err(report_to_response)?; Ok(UpdateDataTypesParams { schema: patch_id_and_parse(&type_to_update, schema) diff --git a/libs/@local/graph/api/src/rest/legacy/entity_type.rs b/libs/@local/graph/api/src/rest/legacy/entity_type.rs index 28149a7a5f6..3954d9a5669 100644 --- a/libs/@local/graph/api/src/rest/legacy/entity_type.rs +++ b/libs/@local/graph/api/src/rest/legacy/entity_type.rs @@ -833,17 +833,11 @@ where provenance, }) = body; - type_to_update.version.major = - type_to_update.version.major.checked_add(1).ok_or_else(|| { - status_to_response(Status::new( - hash_status::StatusCode::InvalidArgument, - Some(format!( - "Type version cannot be incremented beyond {}", - u32::MAX - )), - vec![], - )) - })?; + type_to_update.version.major = type_to_update + .version + .major + .next() + .map_err(report_to_response)?; let entity_type = patch_id_and_parse(&type_to_update, schema).map_err(report_to_response)?; @@ -904,17 +898,11 @@ where mut type_to_update, provenance, }| { - type_to_update.version.major = - type_to_update.version.major.checked_add(1).ok_or_else(|| { - status_to_response(Status::new( - hash_status::StatusCode::InvalidArgument, - Some(format!( - "Type version cannot be incremented beyond {}", - u32::MAX - )), - vec![], - )) - })?; + type_to_update.version.major = type_to_update + .version + .major + .next() + .map_err(report_to_response)?; Ok(UpdateEntityTypesParams { schema: patch_id_and_parse(&type_to_update, schema) diff --git a/libs/@local/graph/api/src/rest/legacy/property_type.rs b/libs/@local/graph/api/src/rest/legacy/property_type.rs index beaa9dce4cf..47ec86978ef 100644 --- a/libs/@local/graph/api/src/rest/legacy/property_type.rs +++ b/libs/@local/graph/api/src/rest/legacy/property_type.rs @@ -494,17 +494,11 @@ where provenance, }) = body; - type_to_update.version.major = - type_to_update.version.major.checked_add(1).ok_or_else(|| { - status_to_response(Status::<()>::new( - hash_status::StatusCode::InvalidArgument, - Some(format!( - "Type version cannot be incremented beyond {}", - u32::MAX - )), - vec![], - )) - })?; + type_to_update.version.major = type_to_update + .version + .major + .next() + .map_err(report_to_response)?; let property_type = patch_id_and_parse(&type_to_update, schema).map_err(report_to_response)?; @@ -565,17 +559,11 @@ where mut type_to_update, provenance, }| { - type_to_update.version.major = - type_to_update.version.major.checked_add(1).ok_or_else(|| { - status_to_response(Status::<()>::new( - hash_status::StatusCode::InvalidArgument, - Some(format!( - "Type version cannot be incremented beyond {}", - u32::MAX - )), - vec![], - )) - })?; + type_to_update.version.major = type_to_update + .version + .major + .next() + .map_err(report_to_response)?; Ok(UpdatePropertyTypesParams { schema: patch_id_and_parse(&type_to_update, schema) diff --git a/libs/@local/graph/api/src/rest/legacy/status.rs b/libs/@local/graph/api/src/rest/legacy/status.rs index ce9af6ee4db..16ea83903d8 100644 --- a/libs/@local/graph/api/src/rest/legacy/status.rs +++ b/libs/@local/graph/api/src/rest/legacy/status.rs @@ -13,6 +13,7 @@ use hash_graph_postgres_store::store::error::BaseUrlAlreadyExists; use hash_graph_store::entity::EntityValidationReport; use hash_status::{Status as HashStatus, StatusCode}; use serde::{Deserialize, Serialize}; +use type_system::ontology::id::OntologyTypeMajorVersionError; /// Generalized information about an error, covering its cause, its origin and a collection of /// weakly-typed metadata. @@ -106,6 +107,8 @@ where .unwrap_or_else(|| { if report.contains::() { StatusCode::AlreadyExists + } else if report.contains::() { + StatusCode::InvalidArgument } else { StatusCode::Unknown } @@ -163,3 +166,43 @@ where status_to_response(HashStatus::new(status_code, Some(message), vec![report])) } } + +#[cfg(test)] +mod tests { + use axum::{body::to_bytes, response::IntoResponse as _}; + use type_system::ontology::id::OntologyTypeMajorVersion; + + use super::report_to_response; + + #[tokio::test] + async fn report_version_overflow() { + let report = OntologyTypeMajorVersion::MAX + .next() + .expect_err("the maximum version should have no successor"); + let response = report_to_response(report).into_response(); + assert_eq!( + response.status(), + axum::http::StatusCode::BAD_REQUEST, + "version overflow should be a client error" + ); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("the error response should be readable"); + let status: serde_json::Value = + serde_json::from_slice(&body).expect("the error response should contain JSON"); + assert_eq!( + status["code"], "INVALID_ARGUMENT", + "overflow should retain its status code" + ); + assert_eq!( + status["message"], "Type version cannot be incremented beyond 4294967295", + "overflow should retain its error message" + ); + assert!( + status["contents"] + .as_array() + .is_some_and(|contents| !contents.is_empty()), + "the response should include the structured error report" + ); + } +} diff --git a/libs/@local/graph/api/src/rest/legacy/utoipa_typedef/subgraph/edges.rs b/libs/@local/graph/api/src/rest/legacy/utoipa_typedef/subgraph/edges.rs index d8e8ecdbf8d..c95ebad0331 100644 --- a/libs/@local/graph/api/src/rest/legacy/utoipa_typedef/subgraph/edges.rs +++ b/libs/@local/graph/api/src/rest/legacy/utoipa_typedef/subgraph/edges.rs @@ -240,7 +240,6 @@ impl ToSchema<'_> for Edges { #[cfg(test)] mod tests { - use core::num::NonZero; use hash_graph_store::subgraph::{ edges::{EdgeDirection, KnowledgeGraphEdgeKind, SharedEdgeKind}, @@ -251,7 +250,7 @@ mod tests { }; use type_system::{ knowledge::entity::id::{EntityId, EntityUuid}, - ontology::id::{BaseUrl, OntologyTypeVersion}, + ontology::id::{BaseUrl, OntologyTypeMajorVersion, OntologyTypeVersion}, principal::actor_group::WebId, }; use uuid::Uuid; @@ -297,7 +296,7 @@ mod tests { base_id: BaseUrl::new("https://example.com/".to_owned()) .expect("should be valid URL"), revision_id: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }, diff --git a/libs/@local/graph/authorization/rust/src/policies/cedar/expression_tree.rs b/libs/@local/graph/authorization/rust/src/policies/cedar/expression_tree.rs index 9c81c3457c0..b16fc0ed93c 100644 --- a/libs/@local/graph/authorization/rust/src/policies/cedar/expression_tree.rs +++ b/libs/@local/graph/authorization/rust/src/policies/cedar/expression_tree.rs @@ -378,7 +378,7 @@ impl PolicyExpressionTree { .change_context(ParseBinaryExpressionError::Right) .map(|major| { Self::OntologyTypeVersion(OntologyTypeVersion { - major, + major: major.into(), pre_release: None, }) }) diff --git a/libs/@local/graph/authorization/rust/src/policies/resource/data_type.rs b/libs/@local/graph/authorization/rust/src/policies/resource/data_type.rs index f044371090c..48d55234bbd 100644 --- a/libs/@local/graph/authorization/rust/src/policies/resource/data_type.rs +++ b/libs/@local/graph/authorization/rust/src/policies/resource/data_type.rs @@ -1,5 +1,5 @@ use alloc::{borrow::Cow, sync::Arc}; -use core::{error::Error, fmt, iter, num::NonZeroI64, ptr, str::FromStr as _}; +use core::{error::Error, fmt, iter, ptr, str::FromStr as _}; use std::{collections::HashSet, sync::LazyLock}; use cedar_policy_core::ast; @@ -89,9 +89,9 @@ impl DataTypeResource<'_> { ( SmolStr::new_static("version"), ast::PartialValue::Value(ast::Value::new( - ast::ValueKind::Lit(ast::Literal::Long( - NonZeroI64::from(self.id.as_url().version.major).get(), - )), + ast::ValueKind::Lit(ast::Literal::Long(i64::from( + self.id.as_url().version.major.get(), + ))), None, )), ), diff --git a/libs/@local/graph/authorization/rust/src/policies/resource/entity_type.rs b/libs/@local/graph/authorization/rust/src/policies/resource/entity_type.rs index e1381966058..4ec2c786ce8 100644 --- a/libs/@local/graph/authorization/rust/src/policies/resource/entity_type.rs +++ b/libs/@local/graph/authorization/rust/src/policies/resource/entity_type.rs @@ -1,5 +1,5 @@ use alloc::{borrow::Cow, sync::Arc}; -use core::{error::Error, fmt, iter, num::NonZeroI64, ptr, str::FromStr as _}; +use core::{error::Error, fmt, iter, ptr, str::FromStr as _}; use std::{collections::HashSet, sync::LazyLock}; use cedar_policy_core::ast; @@ -89,9 +89,9 @@ impl EntityTypeResource<'_> { ( SmolStr::new_static("version"), ast::PartialValue::Value(ast::Value::new( - ast::ValueKind::Lit(ast::Literal::Long( - NonZeroI64::from(self.id.as_url().version.major).get(), - )), + ast::ValueKind::Lit(ast::Literal::Long(i64::from( + self.id.as_url().version.major.get(), + ))), None, )), ), diff --git a/libs/@local/graph/authorization/rust/src/policies/resource/property_type.rs b/libs/@local/graph/authorization/rust/src/policies/resource/property_type.rs index bd61a5e7cbc..4ff0ae79d4c 100644 --- a/libs/@local/graph/authorization/rust/src/policies/resource/property_type.rs +++ b/libs/@local/graph/authorization/rust/src/policies/resource/property_type.rs @@ -1,5 +1,5 @@ use alloc::{borrow::Cow, sync::Arc}; -use core::{error::Error, fmt, iter, num::NonZeroI64, ptr, str::FromStr as _}; +use core::{error::Error, fmt, iter, ptr, str::FromStr as _}; use std::{collections::HashSet, sync::LazyLock}; use cedar_policy_core::ast; @@ -89,9 +89,9 @@ impl PropertyTypeResource<'_> { ( SmolStr::new_static("version"), ast::PartialValue::Value(ast::Value::new( - ast::ValueKind::Lit(ast::Literal::Long( - NonZeroI64::from(self.id.as_url().version.major).get(), - )), + ast::ValueKind::Lit(ast::Literal::Long(i64::from( + self.id.as_url().version.major.get(), + ))), None, )), ), diff --git a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs index dc84a7ca109..0f22fe7668e 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/mod.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/mod.rs @@ -3509,10 +3509,8 @@ where major: url .version .major - .get() - .checked_sub(1) - .and_then(NonZero::new) - .ok_or(OntologyVersionDoesNotExist) + .previous() + .change_context(OntologyVersionDoesNotExist) .change_context(UpdateError) .attach("The version of the data type is already at the lowest possible value")?, pre_release: None, diff --git a/libs/@local/graph/postgres-store/src/store/postgres/ontology/data_type.rs b/libs/@local/graph/postgres-store/src/store/postgres/ontology/data_type.rs index 6b4d48de7ad..0384c74c973 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/ontology/data_type.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/ontology/data_type.rs @@ -1,5 +1,5 @@ use alloc::{borrow::Cow, sync::Arc}; -use core::{mem, num::NonZero}; +use core::mem; use std::collections::{HashMap, HashSet}; use error_stack::{Report, ResultExt as _}; @@ -954,10 +954,8 @@ where .id .version .major - .get() - .checked_sub(1) - .and_then(NonZero::new) - .ok_or(OntologyVersionDoesNotExist) + .previous() + .change_context(OntologyVersionDoesNotExist) .change_context(UpdateError) .attach( "The version of the data type is already at the lowest possible value", diff --git a/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs b/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs index dba803b0630..1b980f69900 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/ontology/entity_type.rs @@ -1,5 +1,5 @@ use alloc::{borrow::Cow, collections::BTreeSet, sync::Arc}; -use core::{iter, num::NonZero}; +use core::iter; use std::collections::{HashMap, HashSet}; use error_stack::{Report, ResultExt as _}; @@ -1646,10 +1646,8 @@ where .id .version .major - .get() - .checked_sub(1) - .and_then(NonZero::new) - .ok_or(OntologyVersionDoesNotExist) + .previous() + .change_context(OntologyVersionDoesNotExist) .change_context(UpdateError) .attach( "The version of the entity type is already at the lowest possible \ diff --git a/libs/@local/graph/postgres-store/src/store/postgres/ontology/property_type.rs b/libs/@local/graph/postgres-store/src/store/postgres/ontology/property_type.rs index 7afda239d25..df430f7a560 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/ontology/property_type.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/ontology/property_type.rs @@ -1,5 +1,4 @@ use alloc::borrow::Cow; -use core::num::NonZero; use std::collections::{HashMap, HashSet}; use error_stack::{Report, ResultExt as _}; @@ -870,10 +869,8 @@ where .id .version .major - .get() - .checked_sub(1) - .and_then(NonZero::new) - .ok_or(OntologyVersionDoesNotExist) + .previous() + .change_context(OntologyVersionDoesNotExist) .change_context(UpdateError) .attach( "The version of the property type is already at the lowest possible \ diff --git a/libs/@local/graph/postgres-store/src/store/postgres/query/compile/tests.rs b/libs/@local/graph/postgres-store/src/store/postgres/query/compile/tests.rs index 0fef207b9a3..f528e717f35 100644 --- a/libs/@local/graph/postgres-store/src/store/postgres/query/compile/tests.rs +++ b/libs/@local/graph/postgres-store/src/store/postgres/query/compile/tests.rs @@ -1,5 +1,5 @@ use alloc::borrow::Cow; -use core::{num::NonZero, str::FromStr as _}; +use core::str::FromStr as _; use hash_codec::numeric::Real; use hash_graph_store::{ @@ -2052,7 +2052,7 @@ fn transpile_offset() { mod predefined { use type_system::{ knowledge::entity::id::{DraftId, EntityId, EntityUuid}, - ontology::id::{BaseUrl, OntologyTypeVersion, VersionedUrl}, + ontology::id::{BaseUrl, OntologyTypeMajorVersion, OntologyTypeVersion, VersionedUrl}, principal::actor_group::WebId, }; @@ -2066,7 +2066,7 @@ mod predefined { ) .expect("invalid base url"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; diff --git a/libs/@local/graph/postgres-store/tests/deletion/main.rs b/libs/@local/graph/postgres-store/tests/deletion/main.rs index d559808748e..6935f0298dd 100644 --- a/libs/@local/graph/postgres-store/tests/deletion/main.rs +++ b/libs/@local/graph/postgres-store/tests/deletion/main.rs @@ -11,7 +11,6 @@ mod links; mod purge; mod validation; -use core::num::NonZero; use std::collections::{HashMap, HashSet}; use error_stack::{Report, ResultExt as _}; @@ -45,7 +44,7 @@ use type_system::{ ontology::{ data_type::DataType, entity_type::EntityType, - id::{BaseUrl, OntologyTypeVersion, VersionedUrl}, + id::{BaseUrl, OntologyTypeMajorVersion, OntologyTypeVersion, VersionedUrl}, property_type::PropertyType, provenance::{OntologyOwnership, ProvidedOntologyEditionProvenance}, }, @@ -263,7 +262,7 @@ pub(crate) fn person_type_id() -> VersionedUrl { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, } @@ -276,7 +275,7 @@ pub(crate) fn friend_of_type_id() -> VersionedUrl { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, } diff --git a/libs/@local/graph/store/rust/src/filter/mod.rs b/libs/@local/graph/store/rust/src/filter/mod.rs index a316bd65e9b..81e8aef7ce1 100644 --- a/libs/@local/graph/store/rust/src/filter/mod.rs +++ b/libs/@local/graph/store/rust/src/filter/mod.rs @@ -1523,13 +1523,14 @@ mod tests { reason = "It's fine to error on unused arms in tests" )] - use core::num::NonZero; - use hash_graph_types::ontology::DataTypeLookup; use serde_json::json; use type_system::{ knowledge::entity::id::{DraftId, EntityUuid}, - ontology::data_type::{ClosedDataType, ConversionExpression}, + ontology::{ + data_type::{ClosedDataType, ConversionExpression}, + id::OntologyTypeMajorVersion, + }, principal::actor_group::WebId, }; use uuid::Uuid; @@ -1598,7 +1599,7 @@ mod tests { ) .expect("invalid base url"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; diff --git a/tests/graph/integration/postgres/clustering.rs b/tests/graph/integration/postgres/clustering.rs index bbbd30fe5eb..c951490c924 100644 --- a/tests/graph/integration/postgres/clustering.rs +++ b/tests/graph/integration/postgres/clustering.rs @@ -15,7 +15,7 @@ use type_system::{ entity::{EntityId, id::EntityUuid, provenance::ProvidedEntityEditionProvenance}, property::{PropertyObject, PropertyObjectWithMetadata}, }, - ontology::id::{BaseUrl, OntologyTypeVersion, VersionedUrl}, + ontology::id::{BaseUrl, OntologyTypeMajorVersion, OntologyTypeVersion, VersionedUrl}, principal::{actor::ActorType, actor_group::WebId}, provenance::{OriginProvenance, OriginType}, }; @@ -63,7 +63,7 @@ fn person_entity_type_id() -> VersionedUrl { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, } diff --git a/tests/graph/integration/postgres/drafts.rs b/tests/graph/integration/postgres/drafts.rs index b2b5e6cb415..1725cc5f560 100644 --- a/tests/graph/integration/postgres/drafts.rs +++ b/tests/graph/integration/postgres/drafts.rs @@ -1,4 +1,3 @@ -use core::num::NonZero; use std::collections::HashSet; use hash_graph_store::entity::{CreateEntityParams, EntityStore as _, PatchEntityParams}; @@ -14,7 +13,7 @@ use type_system::{ PropertyPath, PropertyWithMetadata, }, }, - ontology::id::{BaseUrl, OntologyTypeVersion, VersionedUrl}, + ontology::id::{BaseUrl, OntologyTypeMajorVersion, OntologyTypeVersion, VersionedUrl}, principal::{actor::ActorType, actor_group::WebId}, provenance::{OriginProvenance, OriginType}, }; @@ -57,7 +56,7 @@ fn person_entity_type_id() -> VersionedUrl { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, } diff --git a/tests/graph/integration/postgres/email_filter_protection.rs b/tests/graph/integration/postgres/email_filter_protection.rs index a9a14548c00..dfe9c11d033 100644 --- a/tests/graph/integration/postgres/email_filter_protection.rs +++ b/tests/graph/integration/postgres/email_filter_protection.rs @@ -9,7 +9,6 @@ //! The tests below verify each case from the truth tables in the protection module. use alloc::borrow::Cow; -use core::num::NonZero; use std::collections::HashSet; use hash_graph_postgres_store::store::PostgresStoreSettings; @@ -43,7 +42,7 @@ use type_system::{ entity::{LinkData, id::EntityUuid, provenance::ProvidedEntityEditionProvenance}, property::{PropertyObject, PropertyObjectWithMetadata, metadata::PropertyProvenance}, }, - ontology::id::{BaseUrl, OntologyTypeVersion, VersionedUrl}, + ontology::id::{BaseUrl, OntologyTypeMajorVersion, OntologyTypeVersion, VersionedUrl}, principal::{actor::ActorType, actor_group::WebId}, provenance::{OriginProvenance, OriginType}, }; @@ -265,7 +264,7 @@ impl DatabaseApi<'_> { entity_type_ids: HashSet::from([VersionedUrl { base_url: BaseUrl::new(USER_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }]), @@ -300,7 +299,7 @@ impl DatabaseApi<'_> { entity_type_ids: HashSet::from([VersionedUrl { base_url: BaseUrl::new(INVITATION_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }]), @@ -338,14 +337,14 @@ impl DatabaseApi<'_> { VersionedUrl { base_url: BaseUrl::new(USER_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }, VersionedUrl { base_url: BaseUrl::new(INVITATION_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }, @@ -1477,7 +1476,7 @@ impl DatabaseApi<'_> { entity_type_ids: HashSet::from([VersionedUrl { base_url: BaseUrl::new(USER_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }]), @@ -1517,7 +1516,7 @@ impl DatabaseApi<'_> { entity_type_ids: HashSet::from([VersionedUrl { base_url: BaseUrl::new(INVITATION_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }]), @@ -2121,7 +2120,7 @@ impl DatabaseApi<'_> { entity_type_ids: HashSet::from([VersionedUrl { base_url: BaseUrl::new(USER_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }]), @@ -2161,7 +2160,7 @@ impl DatabaseApi<'_> { entity_type_ids: HashSet::from([VersionedUrl { base_url: BaseUrl::new(SECRET_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }]), @@ -2569,7 +2568,7 @@ impl DatabaseApi<'_> { entity_type_ids: HashSet::from([VersionedUrl { base_url: BaseUrl::new(USER_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }]), @@ -3022,7 +3021,7 @@ async fn subgraph_traversal_masks_linked_user_email() { let org_type_id = VersionedUrl { base_url: BaseUrl::new(ORGANIZATION_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; @@ -3030,7 +3029,7 @@ async fn subgraph_traversal_masks_linked_user_email() { let user_type_id = VersionedUrl { base_url: BaseUrl::new(USER_ENTITY_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; @@ -3038,7 +3037,7 @@ async fn subgraph_traversal_masks_linked_user_email() { let link_type_id = VersionedUrl { base_url: BaseUrl::new(HAS_MEMBER_LINK_TYPE_BASE_URL.to_owned()).unwrap(), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; diff --git a/tests/graph/integration/postgres/entity.rs b/tests/graph/integration/postgres/entity.rs index 32bd6e43490..f0d3ca56202 100644 --- a/tests/graph/integration/postgres/entity.rs +++ b/tests/graph/integration/postgres/entity.rs @@ -1,4 +1,3 @@ -use core::num::NonZero; use std::collections::HashSet; use hash_graph_authorization::policies::{Effect, action::ActionName}; @@ -22,7 +21,7 @@ use type_system::{ PropertyPath, PropertyWithMetadata, }, }, - ontology::id::{BaseUrl, OntologyTypeVersion, VersionedUrl}, + ontology::id::{BaseUrl, OntologyTypeMajorVersion, OntologyTypeVersion, VersionedUrl}, principal::{actor::ActorType, actor_group::WebId}, provenance::{OriginProvenance, OriginType}, }; @@ -73,7 +72,7 @@ async fn insert() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }]), @@ -147,7 +146,7 @@ async fn query() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }]), @@ -219,7 +218,7 @@ async fn public_actor_reads_only_publicly_permitted_entities() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }]), @@ -353,7 +352,7 @@ async fn update() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }]), diff --git a/tests/graph/integration/postgres/links.rs b/tests/graph/integration/postgres/links.rs index 4ab4b5256e5..ce30c3fccb1 100644 --- a/tests/graph/integration/postgres/links.rs +++ b/tests/graph/integration/postgres/links.rs @@ -1,5 +1,4 @@ use alloc::borrow::Cow; -use core::num::NonZero; use std::collections::HashSet; use hash_graph_store::{ @@ -20,7 +19,7 @@ use type_system::{ entity::{LinkData, provenance::ProvidedEntityEditionProvenance}, property::{PropertyObject, PropertyObjectWithMetadata, metadata::PropertyProvenance}, }, - ontology::id::{BaseUrl, OntologyTypeVersion, VersionedUrl}, + ontology::id::{BaseUrl, OntologyTypeMajorVersion, OntologyTypeVersion, VersionedUrl}, principal::{actor::ActorType, actor_group::WebId}, provenance::{OriginProvenance, OriginType}, }; @@ -66,7 +65,7 @@ async fn insert() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; @@ -127,7 +126,7 @@ async fn insert() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; @@ -300,7 +299,7 @@ async fn get_entity_links() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; @@ -311,7 +310,7 @@ async fn get_entity_links() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; @@ -322,7 +321,7 @@ async fn get_entity_links() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; @@ -568,7 +567,7 @@ async fn remove_link() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; @@ -579,7 +578,7 @@ async fn remove_link() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; diff --git a/tests/graph/integration/postgres/read_only.rs b/tests/graph/integration/postgres/read_only.rs index baa4ad2b5bb..f094939f1f3 100644 --- a/tests/graph/integration/postgres/read_only.rs +++ b/tests/graph/integration/postgres/read_only.rs @@ -1,4 +1,4 @@ -use core::{iter::once, num::NonZero}; +use core::iter::once; use std::collections::{HashMap, HashSet}; use error_stack::Report; @@ -18,7 +18,7 @@ use type_system::{ }, value::{ValueMetadata, metadata::ValueProvenance}, }, - ontology::id::{BaseUrl, OntologyTypeVersion, VersionedUrl}, + ontology::id::{BaseUrl, OntologyTypeMajorVersion, OntologyTypeVersion, VersionedUrl}, principal::{ actor::{ActorEntityUuid, ActorId, ActorType}, actor_group::WebId, @@ -143,7 +143,7 @@ async fn read_only_modification_matrix() { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; diff --git a/tests/graph/integration/postgres/semantic_search.rs b/tests/graph/integration/postgres/semantic_search.rs index 5d1ba6a6280..e5094c73924 100644 --- a/tests/graph/integration/postgres/semantic_search.rs +++ b/tests/graph/integration/postgres/semantic_search.rs @@ -6,7 +6,6 @@ //! candidate reads, cross-branch deduplication, the exact rerank, and the rank-preserving //! hydration — against ground truth. -use core::num::NonZero; use std::collections::HashSet; use hash_graph_authorization::policies::{Effect, action::ActionName}; @@ -25,7 +24,7 @@ use type_system::{ entity::{EntityId, provenance::ProvidedEntityEditionProvenance}, property::{PropertyObject, PropertyObjectWithMetadata}, }, - ontology::id::{BaseUrl, OntologyTypeVersion, VersionedUrl}, + ontology::id::{BaseUrl, OntologyTypeMajorVersion, OntologyTypeVersion, VersionedUrl}, principal::{ actor::{ActorId, ActorType}, actor_group::WebId, @@ -70,7 +69,7 @@ fn person_entity_type_id() -> VersionedUrl { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, } diff --git a/tests/graph/integration/postgres/sorting.rs b/tests/graph/integration/postgres/sorting.rs index 88058d0d2cc..6664ce40cf0 100644 --- a/tests/graph/integration/postgres/sorting.rs +++ b/tests/graph/integration/postgres/sorting.rs @@ -1,5 +1,4 @@ use alloc::borrow::Cow; -use core::num::NonZero; use std::collections::HashSet; use hash_graph_store::{ @@ -21,7 +20,7 @@ use type_system::{ }, ontology::{ VersionedUrl, - id::{BaseUrl, OntologyTypeVersion}, + id::{BaseUrl, OntologyTypeMajorVersion, OntologyTypeVersion}, }, principal::{actor::ActorType, actor_group::WebId}, provenance::{OriginProvenance, OriginType}, @@ -161,7 +160,7 @@ async fn insert(database: &mut DatabaseTestWrapper) -> DatabaseApi<'_> { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; @@ -171,7 +170,7 @@ async fn insert(database: &mut DatabaseTestWrapper) -> DatabaseApi<'_> { ) .expect("couldn't construct Base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; diff --git a/tests/graph/integration/postgres/table.rs b/tests/graph/integration/postgres/table.rs index 5b4f31ffd64..7309af59464 100644 --- a/tests/graph/integration/postgres/table.rs +++ b/tests/graph/integration/postgres/table.rs @@ -1,4 +1,3 @@ -use core::num::NonZero; use std::collections::{HashMap, HashSet}; use hash_graph_authorization::policies::{ @@ -24,7 +23,7 @@ use type_system::{ }, ontology::{ VersionedUrl, - id::{BaseUrl, OntologyTypeVersion}, + id::{BaseUrl, OntologyTypeMajorVersion, OntologyTypeVersion}, }, principal::{actor::ActorType, actor_group::WebId}, provenance::{OriginProvenance, OriginType}, @@ -39,7 +38,7 @@ fn person_entity_type() -> VersionedUrl { ) .expect("the URL should be a valid base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, } @@ -52,7 +51,7 @@ fn page_entity_type() -> VersionedUrl { ) .expect("the URL should be a valid base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, } @@ -145,7 +144,7 @@ async fn link_rows_carry_their_endpoints() { ) .expect("the URL should be a valid base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; @@ -326,7 +325,7 @@ async fn label_sort_pages_alphabetically() { ) .expect("the URL should be a valid base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; @@ -615,7 +614,7 @@ async fn conversions_convert_row_property_values() { base_url: BaseUrl::new("http://localhost:3000/@alice/types/entity-type/line/".to_owned()) .expect("the URL should be a valid base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; @@ -974,7 +973,7 @@ async fn link_endpoints_hide_entities_the_actor_cannot_view() { ) .expect("the URL should be a valid base URL"), version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }; diff --git a/tests/graph/test-data/rust/src/seeding/producer/data_type.rs b/tests/graph/test-data/rust/src/seeding/producer/data_type.rs index a736c411fb0..c8ed4a14764 100644 --- a/tests/graph/test-data/rust/src/seeding/producer/data_type.rs +++ b/tests/graph/test-data/rust/src/seeding/producer/data_type.rs @@ -1,5 +1,5 @@ use alloc::collections::BTreeSet; -use core::{error::Error, num::NonZero}; +use core::error::Error; use std::collections::HashMap; use error_stack::{Report, ResultExt as _, TryReportTupleExt as _}; @@ -11,7 +11,7 @@ use type_system::ontology::{ DataType, schema::{DataTypeReference, DataTypeSchemaTag, DataTypeTag, ValueLabel}, }, - id::{OntologyTypeVersion, ParseBaseUrlError}, + id::{OntologyTypeMajorVersion, OntologyTypeVersion, ParseBaseUrlError}, json_schema::{SingleValueConstraints, ValueConstraints}, provenance::{OntologyOwnership, ProvidedOntologyEditionProvenance}, }; @@ -216,7 +216,7 @@ impl Producer for DataTypePr slug_from_title(&title) ))?, version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }, diff --git a/tests/graph/test-data/rust/src/seeding/producer/entity_type.rs b/tests/graph/test-data/rust/src/seeding/producer/entity_type.rs index 2b4d919c770..50b95c8bf3f 100644 --- a/tests/graph/test-data/rust/src/seeding/producer/entity_type.rs +++ b/tests/graph/test-data/rust/src/seeding/producer/entity_type.rs @@ -1,4 +1,4 @@ -use core::{error::Error, num::NonZero}; +use core::error::Error; use std::collections::{HashMap, HashSet}; use error_stack::{Report, ResultExt as _, TryReportTupleExt as _}; @@ -13,7 +13,7 @@ use type_system::ontology::{ InverseEntityTypeMetadata, }, }, - id::{OntologyTypeVersion, ParseBaseUrlError}, + id::{OntologyTypeMajorVersion, OntologyTypeVersion, ParseBaseUrlError}, json_schema::ObjectTypeTag, provenance::{OntologyOwnership, ProvidedOntologyEditionProvenance}, }; @@ -290,7 +290,7 @@ impl< slug_from_title(&title) ))?, version: OntologyTypeVersion { - major: NonZero::::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }, diff --git a/tests/graph/test-data/rust/src/seeding/producer/property_type.rs b/tests/graph/test-data/rust/src/seeding/producer/property_type.rs index 81a4759ff98..edc0e15f669 100644 --- a/tests/graph/test-data/rust/src/seeding/producer/property_type.rs +++ b/tests/graph/test-data/rust/src/seeding/producer/property_type.rs @@ -1,11 +1,11 @@ -use core::{error::Error, num::NonZero}; +use core::error::Error; use error_stack::{Report, ResultExt as _, TryReportTupleExt as _}; use hash_graph_store::{property_type::CreatePropertyTypeParams, query::ConflictBehavior}; use rand::{distr::Distribution as _, seq::IndexedRandom as _}; use type_system::ontology::{ BaseUrl, VersionedUrl, - id::{OntologyTypeVersion, ParseBaseUrlError}, + id::{OntologyTypeMajorVersion, OntologyTypeVersion, ParseBaseUrlError}, property_type::{PropertyType, schema::PropertyTypeReference}, provenance::{OntologyOwnership, ProvidedOntologyEditionProvenance}, }; @@ -209,7 +209,7 @@ impl Producer::MIN, + major: OntologyTypeMajorVersion::MIN, pre_release: None, }, }, From 6e8c2d955b6a7ff8482ebb66d16cdf6ea137705b Mon Sep 17 00:00:00 2001 From: Fedor Sakharov Date: Mon, 5 Oct 2026 09:14:53 +0200 Subject: [PATCH 5/6] Add PostgreSQL conversions for ontology major versions --- .../type-system/rust/src/ontology/id/mod.rs | 111 +++++++++++++++--- 1 file changed, 95 insertions(+), 16 deletions(-) diff --git a/libs/@blockprotocol/type-system/rust/src/ontology/id/mod.rs b/libs/@blockprotocol/type-system/rust/src/ontology/id/mod.rs index 3c9257accd4..70869710ba6 100644 --- a/libs/@blockprotocol/type-system/rust/src/ontology/id/mod.rs +++ b/libs/@blockprotocol/type-system/rust/src/ontology/id/mod.rs @@ -35,7 +35,7 @@ use core::error::Error; use core::{ cmp, fmt, - num::{IntErrorKind, NonZeroU32}, + num::{IntErrorKind, NonZero, ParseIntError}, str::FromStr, }; @@ -249,15 +249,15 @@ impl<'a> FromSql<'a> for BaseUrl { derive_more::From, )] #[serde(transparent)] -pub struct OntologyTypeMajorVersion(NonZeroU32); +pub struct OntologyTypeMajorVersion(NonZero); impl OntologyTypeMajorVersion { - pub const MAX: Self = Self(NonZeroU32::MAX); - pub const MIN: Self = Self(NonZeroU32::MIN); + pub const MAX: Self = Self(NonZero::::MAX); + pub const MIN: Self = Self(NonZero::::MIN); #[must_use] pub const fn new(value: u32) -> Option { - match NonZeroU32::new(value) { + match NonZero::new(value) { Some(value) => Some(Self(value)), None => None, } @@ -268,6 +268,19 @@ impl OntologyTypeMajorVersion { self.0.get() } + /// Parses a major version in the given radix. + /// + /// # Errors + /// + /// Returns an error if the input is not an integer between 1 and [`u32::MAX`]. + /// + /// # Panics + /// + /// Panics if `radix` is outside the range 2 to 36. + pub fn from_str_radix(value: &str, radix: u32) -> Result { + NonZero::::from_str_radix(value, radix).map(Self) + } + /// # Errors /// /// Returns [`OntologyTypeMajorVersionError::Overflow`] at [`Self::MAX`]. @@ -289,6 +302,32 @@ impl OntologyTypeMajorVersion { } } +#[cfg(feature = "postgres")] +impl ToSql for OntologyTypeMajorVersion { + postgres_types::accepts!(INT8); + + postgres_types::to_sql_checked!(); + + fn to_sql( + &self, + ty: &Type, + out: &mut BytesMut, + ) -> Result> { + i64::from(self.get()).to_sql(ty, out) + } +} + +#[cfg(feature = "postgres")] +impl<'a> FromSql<'a> for OntologyTypeMajorVersion { + postgres_types::accepts!(INT8); + + fn from_sql(ty: &Type, raw: &'a [u8]) -> Result> { + Ok(Self(NonZero::try_from(u32::try_from(i64::from_sql( + ty, raw, + )?)?)?)) + } +} + /// Pre-release version information for an ontology type. /// /// Represents different pre-release stages following semantic versioning conventions. @@ -458,15 +497,13 @@ impl FromStr for OntologyTypeVersion { }; Ok(Self { - major: NonZeroU32::from_str_radix(version, 10) - .map(OntologyTypeMajorVersion::from) - .map_err(|error| { - if *error.kind() == IntErrorKind::Empty { - ParseOntologyTypeVersionError::MissingVersion - } else { - ParseOntologyTypeVersionError::ParseVersion(error.to_string()) - } - })?, + major: OntologyTypeMajorVersion::from_str_radix(version, 10).map_err(|error| { + if *error.kind() == IntErrorKind::Empty { + ParseOntologyTypeVersionError::MissingVersion + } else { + ParseOntologyTypeVersionError::ParseVersion(error.to_string()) + } + })?, pre_release: draft_info .map(|draft_info| { draft_info.parse().map_err(|error| { @@ -537,7 +574,7 @@ impl ToSql for OntologyTypeVersion { if self.pre_release.is_some() { todo!("https://linear.app/hash/issue/BE-161/allow-ids-for-pre-release-type-to-be-stored-in-postgres"); } - i64::from(self.major.get()).to_sql(ty, out) + self.major.to_sql(ty, out) } } @@ -547,7 +584,7 @@ impl<'a> FromSql<'a> for OntologyTypeVersion { fn from_sql(ty: &Type, raw: &'a [u8]) -> Result> { Ok(Self { - major: NonZeroU32::try_from(u32::try_from(i64::from_sql(ty, raw)?)?)?.into(), + major: OntologyTypeMajorVersion::from_sql(ty, raw)?, pre_release: None, }) } @@ -1099,6 +1136,48 @@ mod tests { .expect_err("zero should be rejected as an ontology version"); } + #[cfg(feature = "postgres")] + #[test] + fn major_version_sql_boundaries() { + for major in [OntologyTypeMajorVersion::MIN, OntologyTypeMajorVersion::MAX] { + let mut buffer = BytesMut::new(); + major + .to_sql_checked(&Type::INT8, &mut buffer) + .expect("major version should serialize as a PostgreSQL integer"); + assert_eq!( + i64::from_sql(&Type::INT8, &buffer) + .expect("major version should use the PostgreSQL INT8 encoding"), + i64::from(major.get()) + ); + assert_eq!( + OntologyTypeMajorVersion::from_sql(&Type::INT8, &buffer) + .expect("major version should deserialize from a PostgreSQL integer"), + major + ); + assert_eq!( + OntologyTypeVersion::from_sql(&Type::INT8, &buffer) + .expect("ontology version should deserialize from a PostgreSQL integer"), + OntologyTypeVersion { + major, + pre_release: None, + } + ); + } + } + + #[cfg(feature = "postgres")] + #[test] + fn major_version_sql_out_of_range() { + for value in [-1_i64, 0, i64::from(u32::MAX) + 1] { + let mut buffer = BytesMut::new(); + value + .to_sql(&Type::INT8, &mut buffer) + .expect("value should serialize as a PostgreSQL integer"); + OntologyTypeMajorVersion::from_sql(&Type::INT8, &buffer) + .expect_err("value outside the major version range should be rejected"); + } + } + #[test] fn ontology_version_ordering_same_major() -> Result<(), Box> { let published = OntologyTypeVersion { From c3bfe13c34438dcd62237b08be4c7b4c5a2dbe3f Mon Sep 17 00:00:00 2001 From: Fedor Sakharov Date: Mon, 5 Oct 2026 11:09:30 +0200 Subject: [PATCH 6/6] Update libs/@local/graph/api/src/rest/legacy/utoipa_typedef/subgraph/edges.rs Co-authored-by: Tim Diekmann <21277928+TimDiekmann@users.noreply.github.com> Signed-off-by: Fedor Sakharov --- .../graph/api/src/rest/legacy/utoipa_typedef/subgraph/edges.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/libs/@local/graph/api/src/rest/legacy/utoipa_typedef/subgraph/edges.rs b/libs/@local/graph/api/src/rest/legacy/utoipa_typedef/subgraph/edges.rs index c95ebad0331..4af64b63da7 100644 --- a/libs/@local/graph/api/src/rest/legacy/utoipa_typedef/subgraph/edges.rs +++ b/libs/@local/graph/api/src/rest/legacy/utoipa_typedef/subgraph/edges.rs @@ -240,7 +240,6 @@ impl ToSchema<'_> for Edges { #[cfg(test)] mod tests { - use hash_graph_store::subgraph::{ edges::{EdgeDirection, KnowledgeGraphEdgeKind, SharedEdgeKind}, identifier::{EntityIdWithInterval, EntityTypeVertexId, EntityVertexId},