From 862268888030793b959d84513e4bb7df2ae83796 Mon Sep 17 00:00:00 2001 From: Chris Whited Date: Mon, 28 Jul 2025 10:08:18 -1000 Subject: [PATCH 1/3] fix(#392 | move typesync api): move the @graphprotocol/typesync mapping api into @graphprotocol/hypergraph package --- .changeset/tired-wasps-matter.md | 21 + apps/events/package.json | 1 - apps/events/src/mapping.ts | 2 +- packages/hypergraph-react/package.json | 2 - .../src/HypergraphAppContext.tsx | 4 +- .../src/internal/use-query-public.tsx | 11 +- packages/hypergraph/package.json | 5 +- packages/hypergraph/src/index.ts | 1 + packages/hypergraph/src/mapping/Mapping.ts | 771 ++++++++++++++++++ packages/hypergraph/src/mapping/Utils.ts | 156 ++++ packages/hypergraph/src/mapping/index.ts | 2 + packages/hypergraph/src/store.ts | 2 +- .../hypergraph/test/mapping/Mapping.test.ts | 430 ++++++++++ .../hypergraph/test/mapping/Utils.test.ts | 46 ++ pnpm-lock.yaml | 9 - 15 files changed, 1437 insertions(+), 26 deletions(-) create mode 100644 .changeset/tired-wasps-matter.md create mode 100644 packages/hypergraph/src/mapping/Mapping.ts create mode 100644 packages/hypergraph/src/mapping/Utils.ts create mode 100644 packages/hypergraph/src/mapping/index.ts create mode 100644 packages/hypergraph/test/mapping/Mapping.test.ts create mode 100644 packages/hypergraph/test/mapping/Utils.test.ts diff --git a/.changeset/tired-wasps-matter.md b/.changeset/tired-wasps-matter.md new file mode 100644 index 00000000..b538ac28 --- /dev/null +++ b/.changeset/tired-wasps-matter.md @@ -0,0 +1,21 @@ +--- +"@graphprotocol/hypergraph-react": minor +"@graphprotocol/hypergraph": minor +--- + +Move @graphprotocol/typesync Mapping and Utils into @graphprotocol/hypergraph package and export from there. Update @graphprotocol/hypergraph-react to use mapping from @graphprotocol/hypergraph. + + +## Changes needed + +Any use of `@graphprotocol/typesync` should use the exported mapping and utils from `@graphprotocol/hypergraph` instead. + +### Example + +```ts +// before +import type { Mapping } from '@graphprotocol/typesync/Mapping' + +// after +import type { Mapping } from '@graphprotocol/hypergraph/mapping' +``` \ No newline at end of file diff --git a/apps/events/package.json b/apps/events/package.json index 27723026..496f15fd 100644 --- a/apps/events/package.json +++ b/apps/events/package.json @@ -11,7 +11,6 @@ "@graphprotocol/grc-20": "^0.21.6", "@graphprotocol/hypergraph": "workspace:*", "@graphprotocol/hypergraph-react": "workspace:*", - "@graphprotocol/typesync": "workspace:*", "@noble/hashes": "^1.8.0", "@radix-ui/react-avatar": "^1.1.9", "@radix-ui/react-icons": "^1.3.2", diff --git a/apps/events/src/mapping.ts b/apps/events/src/mapping.ts index 0ca8e539..7d138bee 100644 --- a/apps/events/src/mapping.ts +++ b/apps/events/src/mapping.ts @@ -1,5 +1,5 @@ import { Id } from '@graphprotocol/grc-20'; -import type { Mapping } from '@graphprotocol/typesync/Mapping'; +import type { Mapping } from '@graphprotocol/hypergraph/mapping'; export const mapping: Mapping = { Event: { diff --git a/packages/hypergraph-react/package.json b/packages/hypergraph-react/package.json index 3dea8bc1..7b806580 100644 --- a/packages/hypergraph-react/package.json +++ b/packages/hypergraph-react/package.json @@ -31,12 +31,10 @@ }, "peerDependencies": { "@graphprotocol/hypergraph": "workspace:*", - "@graphprotocol/typesync": "workspace:*", "react": "^18.0.0 || ^19.0.0" }, "devDependencies": { "@graphprotocol/hypergraph": "workspace:*", - "@graphprotocol/typesync": "workspace:*", "@testing-library/jest-dom": "^6.6.3", "@testing-library/react": "^16.3.0", "@types/react": "^19.1.3", diff --git a/packages/hypergraph-react/src/HypergraphAppContext.tsx b/packages/hypergraph-react/src/HypergraphAppContext.tsx index f575ee93..2d8c15f0 100644 --- a/packages/hypergraph-react/src/HypergraphAppContext.tsx +++ b/packages/hypergraph-react/src/HypergraphAppContext.tsx @@ -15,13 +15,13 @@ import { Inboxes, type InboxMessageStorageEntry, Key, + type Mapping, Messages, SpaceEvents, type SpaceStorageEntry, store, Utils, } from '@graphprotocol/hypergraph'; -import type { Mapping } from '@graphprotocol/typesync/Mapping'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { useSelector as useSelectorStore } from '@xstate/store/react'; import { Effect, Exit } from 'effect'; @@ -219,7 +219,7 @@ export type HypergraphAppProviderProps = Readonly<{ syncServerUri?: string; chainId?: number; children: ReactNode; - mapping: Mapping; + mapping: Mapping.Mapping; appId: string; }>; diff --git a/packages/hypergraph-react/src/internal/use-query-public.tsx b/packages/hypergraph-react/src/internal/use-query-public.tsx index fc726baa..a976bd64 100644 --- a/packages/hypergraph-react/src/internal/use-query-public.tsx +++ b/packages/hypergraph-react/src/internal/use-query-public.tsx @@ -1,6 +1,5 @@ import { Graph } from '@graphprotocol/grc-20'; -import { type Entity, store, Type } from '@graphprotocol/hypergraph'; -import type { Mapping, MappingEntry } from '@graphprotocol/typesync/Mapping'; +import { type Entity, type Mapping, store, Type } from '@graphprotocol/hypergraph'; import { useQuery as useQueryTanstack } from '@tanstack/react-query'; import { useSelector } from '@xstate/store/react'; import * as Either from 'effect/Either'; @@ -172,8 +171,8 @@ const convertPropertyValue = ( const convertRelations = ( queryEntity: RecursiveQueryEntity, type: S, - mappingEntry: MappingEntry, - mapping: Mapping, + mappingEntry: Mapping.MappingEntry, + mapping: Mapping.Mapping, ) => { const rawEntity: Record = {}; @@ -251,8 +250,8 @@ const convertRelations = ( export const parseResult = ( queryData: EntityQueryResult, type: S, - mappingEntry: MappingEntry, - mapping: Mapping, + mappingEntry: Mapping.MappingEntry, + mapping: Mapping.Mapping, ) => { const decode = Schema.decodeUnknownEither(type); const data: Entity.Entity[] = []; diff --git a/packages/hypergraph/package.json b/packages/hypergraph/package.json index 8610f3e3..7e38c31e 100644 --- a/packages/hypergraph/package.json +++ b/packages/hypergraph/package.json @@ -26,6 +26,7 @@ "./identity": "./dist/identity/index.js", "./inboxes": "./dist/inboxes/index.js", "./key": "./dist/key/index.js", + "./mapping": "./dist/mapping/index.js", "./messages": "./dist/messages/index.js", "./space-events": "./dist/space-events/index.js", "./space-info": "./dist/space-info/index.js", @@ -38,11 +39,7 @@ "build": "tsc -b --force tsconfig.build.json && babel dist --plugins annotate-pure-calls --out-dir dist --source-maps && node ../../scripts/package.mjs", "test": "vitest" }, - "peerDependencies": { - "@graphprotocol/typesync": "workspace:*" - }, "devDependencies": { - "@graphprotocol/typesync": "workspace:*", "@types/uuid": "^10.0.0" }, "dependencies": { diff --git a/packages/hypergraph/src/index.ts b/packages/hypergraph/src/index.ts index 6066e410..1d95b205 100644 --- a/packages/hypergraph/src/index.ts +++ b/packages/hypergraph/src/index.ts @@ -3,6 +3,7 @@ export * as Entity from './entity/index.js'; export * as Identity from './identity/index.js'; export * as Inboxes from './inboxes/index.js'; export * as Key from './key/index.js'; +export * as Mapping from './mapping/index.js'; export * as Messages from './messages/index.js'; export * as SpaceEvents from './space-events/index.js'; export * as SpaceInfo from './space-info/index.js'; diff --git a/packages/hypergraph/src/mapping/Mapping.ts b/packages/hypergraph/src/mapping/Mapping.ts new file mode 100644 index 00000000..eaac3f51 --- /dev/null +++ b/packages/hypergraph/src/mapping/Mapping.ts @@ -0,0 +1,771 @@ +import { type CreatePropertyParams, Graph, Id as Grc20Id, type Op } from '@graphprotocol/grc-20'; +import { Data, Array as EffectArray, Schema as EffectSchema, Option, pipe } from 'effect'; + +import { namesAreUnique, toCamelCase, toPascalCase } from './Utils.js'; + +/** + * Mappings for a schema type and its properties/relations + * + * @since 0.2.0 + */ +export type MappingEntry = { + /** + * Array of the `Id.Id` of the type in the Knowledge Graph. + * Is an array because a type can belong to multiple spaces/extend multiple types. + * + * @since 0.2.0 + */ + typeIds: Array; + /** + * Record of property names to the `Id.Id` of the type in the Knowledge Graph + * + * @since 0.2.0 + */ + properties?: + | { + [key: string]: Grc20Id.Id; + } + | undefined; + /** + * Record of relation properties to the `Id.Id` of the type in the Knowledge Graph + * + * @since 0.2.0 + */ + relations?: + | { + [key: string]: Grc20Id.Id; + } + | undefined; +}; + +/** + * @example + * ```ts + * import { Id } from '@graphprotocol/grc-20' + * import type { Mapping } from '@graphprotocol/hypergraph/mapping' + * + * const mapping: Mapping = { + * Account: { + * typeIds: [Id.Id('a5fd07b1-120f-46c6-b46f-387ef98396a6')], + * properties: { + * username: Id.Id('994edcff-6996-4a77-9797-a13e5e3efad8'), + * createdAt: Id.Id('64bfba51-a69b-4746-be4b-213214a879fe') + * } + * }, + * Event: { + * typeIds: [Id.Id('0349187b-526f-435f-b2bb-9e9caf23127a')], + * properties: { + * name: Id.Id('3808e060-fb4a-4d08-8069-35b8c8a1902b'), + * description: Id.Id('1f0d9007-8da2-4b28-ab9f-3bc0709f4837'), + * }, + * relations: { + * speaker: Id.Id('a5fd07b1-120f-46c6-b46f-387ef98396a6') + * } + * } + * } + * ``` + * + * @since 0.2.0 + */ +export type Mapping = { + [key: string]: MappingEntry; +}; + +/** + * @since 0.2.0 + */ +export type DataTypeRelation = `Relation(${string})`; +/** + * @since 0.2.0 + */ +export function isDataTypeRelation(val: string): val is DataTypeRelation { + return /^Relation\((.+)\)$/.test(val); +} +/** + * @since 0.2.0 + */ +export const SchemaDataTypeRelation = EffectSchema.NonEmptyTrimmedString.pipe( + EffectSchema.filter((val) => isDataTypeRelation(val)), +); +/** + * @since 0.2.0 + */ +export type SchemaDataTypeRelation = typeof SchemaDataTypeRelation.Type; +/** + * @since 0.2.0 + */ +export const SchemaDataTypePrimitive = EffectSchema.Literal('Text', 'Number', 'Checkbox', 'Date', 'Point'); +/** + * @since 0.2.0 + */ +export type SchemaDataTypePrimitive = typeof SchemaDataTypePrimitive.Type; +/** + * @since 0.2.0 + */ +export const SchemaDataType = EffectSchema.Union(SchemaDataTypePrimitive, SchemaDataTypeRelation); +/** + * @since 0.2.0 + */ +export type SchemaDataType = typeof SchemaDataType.Type; +/** + * @since 0.2.0 + */ +export const SchemaTypePropertyRelation = EffectSchema.Struct({ + name: EffectSchema.NonEmptyTrimmedString, + knowledgeGraphId: EffectSchema.NullOr(EffectSchema.UUID), + dataType: SchemaDataTypeRelation, + relationType: EffectSchema.NonEmptyTrimmedString.annotations({ + identifier: 'SchemaTypePropertyRelation.relationType', + description: 'name of the type within the schema that this property is related to', + examples: ['Account'], + }), +}); +/** + * @since 0.2.0 + */ +export type SchemaTypePropertyRelation = typeof SchemaTypePropertyRelation.Type; +/** + * @since 0.2.0 + */ +export const SchemaTypePropertyPrimitive = EffectSchema.Struct({ + name: EffectSchema.NonEmptyTrimmedString, + knowledgeGraphId: EffectSchema.NullOr(EffectSchema.UUID), + dataType: SchemaDataTypePrimitive, +}); +/** + * @since 0.2.0 + */ +export type SchemaTypePropertyPrimitive = typeof SchemaTypePropertyPrimitive.Type; + +/** + * @since 0.2.0 + */ +export function propertyIsRelation( + property: SchemaTypePropertyPrimitive | SchemaTypePropertyRelation, +): property is SchemaTypePropertyRelation { + return isDataTypeRelation(property.dataType); +} + +/** + * @since 0.2.0 + */ +export const SchemaType = EffectSchema.Struct({ + name: EffectSchema.NonEmptyTrimmedString, + knowledgeGraphId: EffectSchema.NullOr(EffectSchema.UUID), + properties: EffectSchema.Array(EffectSchema.Union(SchemaTypePropertyPrimitive, SchemaTypePropertyRelation)).pipe( + EffectSchema.minItems(1), + EffectSchema.filter(namesAreUnique, { + identifier: 'DuplicatePropertyNames', + jsonSchema: {}, + description: 'The property.name must be unique across all properties in the type', + }), + ), +}); +/** + * @since 0.2.0 + */ +export type SchemaType = typeof SchemaType.Type; + +/** + * Represents the user-built schema object to generate a `Mappings` definition for + * + * @since 0.2.0 + */ +export const Schema = EffectSchema.Struct({ + types: EffectSchema.Array(SchemaType).pipe( + EffectSchema.minItems(1), + EffectSchema.filter(namesAreUnique, { + identifier: 'DuplicateTypeNames', + jsonSchema: {}, + description: 'The type.name must be unique across all types in the schema', + }), + EffectSchema.filter(allRelationPropertyTypesExist, { + identifier: 'AllRelationTypesExist', + jsonSchema: {}, + description: 'Each type property of dataType RELATION must have a type of the same name in the schema', + }), + ), +}).annotations({ + identifier: 'typesync/Schema', + title: 'TypeSync app Schema', + description: 'An array of types in the schema defined by the user to generate a Mapping object for', + examples: [ + { + types: [ + { + name: 'Account', + knowledgeGraphId: null, + properties: [{ name: 'username', knowledgeGraphId: null, dataType: 'Text' }], + }, + ], + }, + { + types: [ + { + name: 'Account', + knowledgeGraphId: 'a5fd07b1-120f-46c6-b46f-387ef98396a6', + properties: [{ name: 'name', knowledgeGraphId: 'a126ca53-0c8e-48d5-b888-82c734c38935', dataType: 'Text' }], + }, + ], + }, + ], +}); +/** + * @since 0.2.0 + */ +export type Schema = typeof Schema.Type; +/** + * @since 0.2.0 + */ +export const SchemaKnownDecoder = EffectSchema.decodeSync(Schema); +/** + * @since 0.2.0 + */ +export const SchemaUnknownDecoder = EffectSchema.decodeUnknownSync(Schema); + +/** + * Iterate through all properties in all types in the schema of `dataType` === `Relation(${string})` + * and validate that the schema.types have a type for the existing relation + * + * @example All types exist + * ```ts + * import { allRelationPropertyTypesExist, type Mapping } from '@graphprotocol/hypergraph/mapping' + * + * const types: Mapping['types'] = [ + * { + * name: "Account", + * knowledgeGraphId: null, + * properties: [ + * { + * name: "username", + * dataType: "Text", + * knowledgeGraphId: null + * } + * ] + * }, + * { + * name: "Event", + * knowledgeGraphId: null, + * properties: [ + * { + * name: "speaker", + * dataType: "Relation(Account)" + * relationType: "Account", + * knowledgeGraphId: null, + * } + * ] + * } + * ] + * expect(allRelationPropertyTypesExist(types)).toEqual(true) + * ``` + * + * @example Account type is missing + * ```ts + * import { allRelationPropertyTypesExist, type Mapping } from '@graphprotocol/hypergraph/mapping' + * + * const types: Mapping['types'] = [ + * { + * name: "Event", + * knowledgeGraphId: null, + * properties: [ + * { + * name: "speaker", + * dataType: "Relation(Account)", + * relationType: "Account", + * knowledgeGraphId: null, + * } + * ] + * } + * ] + * expect(allRelationPropertyTypesExist(types)).toEqual(false) + * ``` + * + * @since 0.2.0 + * + * @param types the user-submitted schema types + */ +export function allRelationPropertyTypesExist(types: ReadonlyArray): boolean { + const unqTypeNames = EffectArray.reduce(types, new Set(), (names, curr) => names.add(curr.name)); + return pipe( + types, + EffectArray.flatMap((curr) => curr.properties), + EffectArray.filter((prop) => propertyIsRelation(prop)), + EffectArray.every((prop) => unqTypeNames.has(prop.relationType)), + ); +} + +export type GenerateMappingResult = [mapping: Mapping, ops: ReadonlyArray]; + +// Helper types for internal processing +type PropertyIdMapping = { propName: string; id: Grc20Id.Id }; +type TypeIdMapping = Map; +type ProcessedProperty = + | { type: 'resolved'; mapping: PropertyIdMapping; ops: Array } + | { type: 'deferred'; property: SchemaTypePropertyRelation }; + +type ProcessedType = + | { type: 'complete'; entry: MappingEntry & { typeName: string }; ops: Array } + | { + type: 'deferred'; + schemaType: SchemaType; + properties: Array; + relations: Array; + }; + +// Helper function to build property map from PropertyIdMappings +function buildPropertyMap(properties: Array): MappingEntry['properties'] { + return pipe( + properties, + EffectArray.reduce({} as NonNullable, (props, { propName, id }) => { + props[toCamelCase(propName)] = id; + return props; + }), + ); +} + +// Helper function to build relation map from PropertyIdMappings +function buildRelationMap(relations: Array): MappingEntry['relations'] { + return pipe( + relations, + EffectArray.reduce({} as NonNullable, (rels, { propName, id }) => { + rels[toCamelCase(propName)] = id; + return rels; + }), + ); +} + +// Helper function to create a property and return the result +function createPropertyWithOps( + property: SchemaTypePropertyPrimitive | SchemaTypePropertyRelation, + typeIdMap: TypeIdMapping, +): ProcessedProperty { + if (property.knowledgeGraphId) { + return { + type: 'resolved', + mapping: { propName: property.name, id: Grc20Id.Id(property.knowledgeGraphId) }, + ops: [], + }; + } + + if (propertyIsRelation(property)) { + const relationTypeId = typeIdMap.get(property.relationType); + if (relationTypeId == null) { + return { type: 'deferred', property }; + } + + const { id, ops } = Graph.createProperty({ + name: property.name, + dataType: 'RELATION', + relationValueTypes: [relationTypeId], + }); + return { + type: 'resolved', + mapping: { propName: property.name, id }, + ops, + }; + } + + const { id, ops } = Graph.createProperty({ + name: property.name, + dataType: mapSchemaDataTypeToGRC20PropDataType(property.dataType), + }); + return { + type: 'resolved', + mapping: { propName: property.name, id }, + ops, + }; +} + +// Helper function to process a single type +function processType(type: SchemaType, typeIdMap: TypeIdMapping): ProcessedType { + const processedProperties = pipe( + type.properties, + EffectArray.map((prop) => createPropertyWithOps(prop, typeIdMap)), + ); + + const resolvedProperties = pipe( + processedProperties, + EffectArray.filterMap((p) => (p.type === 'resolved' ? Option.some(p) : Option.none())), + ); + + const deferredProperties = pipe( + processedProperties, + EffectArray.filterMap((p) => (p.type === 'deferred' ? Option.some(p.property) : Option.none())), + ); + + // Separate resolved properties into primitive properties and relations + const primitiveProperties = pipe( + resolvedProperties, + EffectArray.filter((p) => { + const originalProp = type.properties.find((prop) => prop.name === p.mapping.propName); + return originalProp ? !propertyIsRelation(originalProp) : false; + }), + EffectArray.map((p) => p.mapping), + ); + + const relationProperties = pipe( + resolvedProperties, + EffectArray.filter((p) => { + const originalProp = type.properties.find((prop) => prop.name === p.mapping.propName); + return originalProp ? propertyIsRelation(originalProp) : false; + }), + EffectArray.map((p) => p.mapping), + ); + + const propertyOps = pipe( + resolvedProperties, + EffectArray.flatMap((p) => p.ops), + ); + + // If type exists in knowledge graph, return complete entry + if (type.knowledgeGraphId) { + const entry: MappingEntry & { typeName: string } = { + typeName: toPascalCase(type.name), + typeIds: [Grc20Id.Id(type.knowledgeGraphId)], + }; + + if (EffectArray.isNonEmptyArray(primitiveProperties)) { + entry.properties = buildPropertyMap(primitiveProperties); + } + + if (EffectArray.isNonEmptyArray(relationProperties)) { + entry.relations = buildRelationMap(relationProperties); + } + + return { + type: 'complete', + entry, + ops: propertyOps, + }; + } + + // If there are deferred properties, defer type creation + if (EffectArray.isNonEmptyArray(deferredProperties)) { + return { + type: 'deferred', + schemaType: type, + properties: primitiveProperties, + relations: relationProperties, + }; + } + + // Create the type with all resolved properties (both primitive and relations) + const allPropertyIds = [...primitiveProperties, ...relationProperties]; + const { id, ops: typeOps } = Graph.createType({ + name: type.name, + properties: pipe( + allPropertyIds, + EffectArray.map((p) => p.id), + ), + }); + + typeIdMap.set(type.name, id); + + const entry: MappingEntry & { typeName: string } = { + typeName: toPascalCase(type.name), + typeIds: [id], + }; + + if (EffectArray.isNonEmptyArray(primitiveProperties)) { + entry.properties = buildPropertyMap(primitiveProperties); + } + + if (EffectArray.isNonEmptyArray(relationProperties)) { + entry.relations = buildRelationMap(relationProperties); + } + + return { + type: 'complete', + entry, + ops: [...propertyOps, ...typeOps], + }; +} + +/** + * Takes the user-submitted schema, validates it, and build the `Mapping` definition for the schema as well as the GRC-20 Ops needed to publish the schema/schema changes to the Knowledge Graph. + * + * @example + * ```ts + * import { Id } from "@graphprotocol/grc-20" + * import { generateMapping } from "@graphprotocol/typesync" + * + * const schema: Schema = { + * types: [ + * { + * name: "Account", + * knowledgeGraphId: "a5fd07b1-120f-46c6-b46f-387ef98396a6", + * properties: [ + * { + * name: "username", + * dataType: "Text", + * knowledgeGraphId: "994edcff-6996-4a77-9797-a13e5e3efad8" + * }, + * { + * name: "createdAt", + * dataType: "Date", + * knowledgeGraphId: null + * } + * ] + * }, + * { + * name: "Event", + * knowledgeGraphId: null, + * properties: [ + * { + * name: "name", + * dataType: "Text", + * knowledgeGraphId: "3808e060-fb4a-4d08-8069-35b8c8a1902b" + * }, + * { + * name: "description", + * dataType: "Text", + * knowledgeGraphId: null + * }, + * { + * name: "speaker", + * dataType: "Relation(Account)", + * relationType: "Account", + * knowledgeGraphId: null + * } + * ] + * } + * ], + * } + * const [mapping, ops] = generateMapping(schema) + * + * expect(mapping).toEqual({ + * Account: { + * typeIds: [Id.Id("a5fd07b1-120f-46c6-b46f-387ef98396a6")], // comes from input schema + * properties: { + * username: Id.Id("994edcff-6996-4a77-9797-a13e5e3efad8"), // comes from input schema + * createdAt: Id.Id("8cd7d9ac-a878-4287-8000-e71e6f853117"), // generated from Graph.createProperty Op + * } + * }, + * Event: { + * typeIds: [Id.Id("20b3fe39-8e62-41a0-b9cb-92743fd760da")], // generated from Graph.createType Op + * properties: { + * name: Id.Id("3808e060-fb4a-4d08-8069-35b8c8a1902b"), // comes from input schema + * description: Id.Id("8fc4e17c-7581-4d6c-a712-943385afc7b5"), // generated from Graph.createProperty Op + * }, + * relations: { + * speaker: Id.Id("651ce59f-643b-4931-bf7a-5dc0ca0f5a47"), // generated from Graph.createProperty Op + * } + * } + * }) + * expect(ops).toEqual([ + * // Graph.createProperty Op for Account.createdAt property + * { + * type: "CREATE_PROPERTY", + * property: { + * id: Id.Id("8cd7d9ac-a878-4287-8000-e71e6f853117"), + * dataType: "TEXT" + * } + * }, + * // Graph.createProperty Op for Event.description property + * { + * type: "CREATE_PROPERTY", + * property: { + * id: Id.Id("8fc4e17c-7581-4d6c-a712-943385afc7b5"), + * dataType: "TEXT" + * } + * }, + * // Graph.createProperty Op for Event.speaker property + * { + * type: "CREATE_PROPERTY", + * property: { + * id: Id.Id("651ce59f-643b-4931-bf7a-5dc0ca0f5a47"), + * dataType: "RELATION" + * } + * }, + * // Graph.createType Op for Event type + * { + * type: "CREATE_PROPERTY", + * property: { + * id: Id.Id("651ce59f-643b-4931-bf7a-5dc0ca0f5a47"), + * dataType: "RELATION" + * } + * }, + * ]) + * ``` + * + * @since 0.2.0 + * + * @param input user-built and submitted schema + * @returns the generated [Mapping] definition from the submitted schema as well as the GRC-20 Ops required to publish the schema to the Knowledge Graph + */ +export function generateMapping(input: Schema): GenerateMappingResult { + // Validate the schema + const schema = SchemaKnownDecoder(input); + + // Build initial type ID map + const typeIdMap: TypeIdMapping = pipe( + schema.types, + EffectArray.reduce(new Map(), (map, type) => + map.set(type.name, type.knowledgeGraphId != null ? Grc20Id.Id(type.knowledgeGraphId) : null), + ), + ); + + // First pass: process all types + const processedTypes = pipe( + schema.types, + EffectArray.map((type) => processType(type, typeIdMap)), + ); + + // Separate complete and deferred types + const [deferredTypes, completeTypes] = pipe( + processedTypes, + EffectArray.partition( + (result): result is Extract => result.type === 'complete', + ), + ); + + // Collect all operations from first pass + const firstPassOps = pipe( + completeTypes, + EffectArray.flatMap((t) => t.ops), + ); + + // Second pass: resolve deferred relation properties and create deferred types + const { entries: deferredEntries, ops: secondPassOps } = pipe( + deferredTypes, + EffectArray.reduce( + { entries: [] as Array, ops: [] as Array }, + (acc, deferred) => { + // Resolve all deferred relation properties for this type + const resolvedRelations = pipe( + deferred.schemaType.properties, + EffectArray.filterMap((prop) => { + if (!propertyIsRelation(prop) || prop.knowledgeGraphId != null) { + return Option.none(); + } + + const relationTypeId = typeIdMap.get(prop.relationType); + if (relationTypeId == null) { + throw new RelationValueTypeDoesNotExistError({ + message: `Failed to resolve type ID for relation type: ${prop.relationType}`, + property: prop.name, + relatedType: prop.relationType, + }); + } + + const { id, ops } = Graph.createProperty({ + name: prop.name, + dataType: 'RELATION', + relationValueTypes: [relationTypeId], + }); + + return Option.some({ mapping: { propName: prop.name, id }, ops }); + }), + ); + + // Combine resolved relations with existing relations + const allRelations = [ + ...deferred.relations, + ...pipe( + resolvedRelations, + EffectArray.map((r) => r.mapping), + ), + ]; + + // Combine all property IDs for type creation + const allPropertyIds = [...deferred.properties, ...allRelations]; + + // Create the type with all properties + const { id, ops: typeOps } = Graph.createType({ + name: deferred.schemaType.name, + properties: pipe( + allPropertyIds, + EffectArray.map((p) => p.id), + ), + }); + + typeIdMap.set(deferred.schemaType.name, id); + + // Collect all operations + const allOps = [ + ...pipe( + resolvedRelations, + EffectArray.flatMap((r) => r.ops), + ), + ...typeOps, + ]; + + // Build the entry with properties and relations separated + const entry: MappingEntry & { typeName: string } = { + typeName: toPascalCase(deferred.schemaType.name), + typeIds: [id], + }; + + if (EffectArray.isNonEmptyArray(deferred.properties)) { + entry.properties = buildPropertyMap(deferred.properties); + } + + if (EffectArray.isNonEmptyArray(allRelations)) { + entry.relations = buildRelationMap(allRelations); + } + + return { + entries: [...acc.entries, entry], + ops: [...acc.ops, ...allOps], + }; + }, + ), + ); + + // Combine all entries and build final mapping + const allEntries = [ + ...pipe( + completeTypes, + EffectArray.map((t) => t.entry), + ), + ...deferredEntries, + ]; + + const mapping = pipe( + allEntries, + EffectArray.reduce({} as Mapping, (mapping, entry) => { + const { typeName, ...rest } = entry; + mapping[typeName] = rest; + return mapping; + }), + ); + + return [mapping, [...firstPassOps, ...secondPassOps]] as const; +} + +export class RelationValueTypeDoesNotExistError extends Data.TaggedError( + '/typesync/errors/RelationValueTypeDoesNotExistError', +)<{ + readonly message: string; + readonly property: string; + readonly relatedType: string; +}> {} + +/** + * @since 0.2.0 + * + * @param dataType the dataType from the user-submitted schema + * @returns the mapped to GRC-20 dataType for the GRC-20 ops + */ +export function mapSchemaDataTypeToGRC20PropDataType(dataType: SchemaDataType): CreatePropertyParams['dataType'] { + switch (true) { + case dataType === 'Checkbox': { + return 'CHECKBOX'; + } + case dataType === 'Date': { + return 'TIME'; + } + case dataType === 'Number': { + return 'NUMBER'; + } + case dataType === 'Point': { + return 'POINT'; + } + case isDataTypeRelation(dataType): { + return 'RELATION'; + } + default: { + return 'TEXT'; + } + } +} diff --git a/packages/hypergraph/src/mapping/Utils.ts b/packages/hypergraph/src/mapping/Utils.ts new file mode 100644 index 00000000..3c640e24 --- /dev/null +++ b/packages/hypergraph/src/mapping/Utils.ts @@ -0,0 +1,156 @@ +import { Data, String as EffectString } from 'effect'; + +/** + * Takes the input string and returns the camelCase equivalent + * + * @example + * ```ts + * import { toCamelCase } from '@graphprotocol/hypergraph/mapping' + * + * expect(toCamelCase('Address line 1')).toEqual('addressLine1'); + * expect(toCamelCase('AddressLine1')).toEqual('addressLine1'); + * expect(toCamelCase('addressLine1')).toEqual('addressLine1'); + * expect(toCamelCase('address_line_1')).toEqual('addressLine1'); + * expect(toCamelCase('address-line-1')).toEqual('addressLine1'); + * expect(toCamelCase('address-line_1')).toEqual('addressLine1'); + * expect(toCamelCase('address-line 1')).toEqual('addressLine1'); + * expect(toCamelCase('ADDRESS_LINE_1')).toEqual('addressLine1'); + * ``` + * + * @since 0.2.0 + * + * @param str input string + * @returns camelCased value of the input string + */ +export function toCamelCase(str: string): string { + if (EffectString.isEmpty(str)) { + throw new InvalidInputError({ input: str, cause: 'Input is empty' }); + } + + let result = ''; + let capitalizeNext = false; + let i = 0; + + // Skip leading non-alphanumeric characters + while (i < EffectString.length(str) && !/[a-zA-Z0-9]/.test(str[i])) { + i++; + } + + for (; i < EffectString.length(str); i++) { + const char = str[i]; + + if (/[a-zA-Z0-9]/.test(char)) { + if (capitalizeNext) { + result += EffectString.toUpperCase(char); + capitalizeNext = false; + } else if (EffectString.length(result) === 0) { + // First character should always be lowercase + result += EffectString.toLowerCase(char); + } else if (/[A-Z]/.test(char) && i > 0 && /[a-z0-9]/.test(str[i - 1])) { + // Capital letter following lowercase/number - this indicates a word boundary + // So we need to capitalize this letter (it starts a new word) + result += EffectString.toUpperCase(char); + } else { + result += EffectString.toLowerCase(char); + } + } else { + // Non-alphanumeric character - set flag to capitalize next letter + capitalizeNext = EffectString.length(result) > 0; // Only capitalize if we have existing content + } + } + + return result; +} + +/** + * Takes the input string and returns the PascalCase equivalent + * + * @example + * ```ts + * iimport { toPascalCase } from '@graphprotocol/hypergraph/mapping' + * + * expect(toPascalCase('Address line 1')).toEqual('AddressLine1'); + * expect(toPascalCase('AddressLine1')).toEqual('AddressLine1'); + * expect(toPascalCase('addressLine1')).toEqual('AddressLine1'); + * expect(toPascalCase('address_line_1')).toEqual('AddressLine1'); + * expect(toPascalCase('address-line-1')).toEqual('AddressLine1'); + * expect(toPascalCase('address-line_1')).toEqual('AddressLine1'); + * expect(toPascalCase('address-line 1')).toEqual('AddressLine1'); + * expect(toPascalCase('ADDRESS_LINE_1')).toEqual('AddressLine1'); + * ``` + * + * @since 0.2.0 + * + * @param str input string + * @returns PascalCased value of the input string + */ +export function toPascalCase(str: string): string { + if (EffectString.isEmpty(str)) { + throw new InvalidInputError({ input: str, cause: 'Input is empty' }); + } + + let result = ''; + let capitalizeNext = true; // Start with true to capitalize the first letter + let i = 0; + + // Skip leading non-alphanumeric characters + while (i < EffectString.length(str) && !/[a-zA-Z0-9]/.test(str[i])) { + i++; + } + + for (; i < EffectString.length(str); i++) { + const char = str[i]; + + if (/[a-zA-Z0-9]/.test(char)) { + if (capitalizeNext) { + result += EffectString.toUpperCase(char); + capitalizeNext = false; + } else if (/[A-Z]/.test(char) && i > 0 && /[a-z0-9]/.test(str[i - 1])) { + // Capital letter following lowercase/number - this indicates a word boundary + // So we need to capitalize this letter (it starts a new word) + result += EffectString.toUpperCase(char); + } else { + result += EffectString.toLowerCase(char); + } + } else { + // Non-alphanumeric character - set flag to capitalize next letter + capitalizeNext = true; + } + } + + return result; +} + +export class InvalidInputError extends Data.TaggedError('/typesync/errors/InvalidInputError')<{ + readonly input: string; + readonly cause: unknown; +}> {} + +/** + * Adds schema validation that the array of objects with property `name` only has unique names + * + * @example only unique names -> returns true + * ```ts + * const types = [{name:'Account'}, {name:'Event'}] + * expect(namesAreUnique(types)).toEqual(true) + * ``` + * + * @example duplicate name -> returns false + * ```ts + * const types = [{name:'Account'}, {name:'Event'}, {name:'Account'}] + * expect(namesAreUnique(types)).toEqual(false) + * ``` + */ +export function namesAreUnique(entries: ReadonlyArray): boolean { + const names = new Set(); + + for (const entry of entries) { + const name = EffectString.toLowerCase(entry.name); + if (names.has(name)) { + return false; + } + names.add(name); + } + + return true; +} diff --git a/packages/hypergraph/src/mapping/index.ts b/packages/hypergraph/src/mapping/index.ts new file mode 100644 index 00000000..eb4a85a9 --- /dev/null +++ b/packages/hypergraph/src/mapping/index.ts @@ -0,0 +1,2 @@ +export * from './Mapping.js'; +export * from './Utils.js'; diff --git a/packages/hypergraph/src/store.ts b/packages/hypergraph/src/store.ts index 2b9bbd36..78566fc6 100644 --- a/packages/hypergraph/src/store.ts +++ b/packages/hypergraph/src/store.ts @@ -1,10 +1,10 @@ import type { AnyDocumentId, DocHandle, Repo } from '@automerge/automerge-repo'; -import type { Mapping } from '@graphprotocol/typesync/Mapping'; import { createStore, type Store } from '@xstate/store'; import type { PrivateAppIdentity } from './connect/types.js'; import type { DocumentContent } from './entity/types.js'; import { mergeMessages } from './inboxes/merge-messages.js'; import type { InboxSenderAuthPolicy } from './inboxes/types.js'; +import type { Mapping } from './mapping/Mapping.js'; import type { Invitation, Updates } from './messages/index.js'; import type { SpaceEvent, SpaceState } from './space-events/index.js'; import { idToAutomergeId } from './utils/automergeId.js'; diff --git a/packages/hypergraph/test/mapping/Mapping.test.ts b/packages/hypergraph/test/mapping/Mapping.test.ts new file mode 100644 index 00000000..ca15460b --- /dev/null +++ b/packages/hypergraph/test/mapping/Mapping.test.ts @@ -0,0 +1,430 @@ +import { Id } from '@graphprotocol/grc-20'; +import { describe, expect, it } from 'vitest'; + +import { + allRelationPropertyTypesExist, + generateMapping, + type Mapping, + mapSchemaDataTypeToGRC20PropDataType, + type Schema, +} from '../../src/mapping/Mapping.js'; + +describe('Mapping', () => { + describe('mapSchemaDataTypeToGRC20PropDataType', () => { + it('should be able to map the schema dataType to the correct GRC-20 dataType', () => { + expect(mapSchemaDataTypeToGRC20PropDataType('Checkbox')).toEqual('CHECKBOX'); + expect(mapSchemaDataTypeToGRC20PropDataType('Number')).toEqual('NUMBER'); + expect(mapSchemaDataTypeToGRC20PropDataType('Date')).toEqual('TIME'); + expect(mapSchemaDataTypeToGRC20PropDataType('Point')).toEqual('POINT'); + expect(mapSchemaDataTypeToGRC20PropDataType('Text')).toEqual('TEXT'); + expect(mapSchemaDataTypeToGRC20PropDataType('Relation(Event)')).toEqual('RELATION'); + }); + }); + + describe('allRelationPropertyTypesExist', () => { + it('should return true if the submitted schema contains all required types', () => { + const types: Schema['types'] = [ + { + name: 'Account', + knowledgeGraphId: null, + properties: [{ name: 'username', dataType: 'Text', knowledgeGraphId: null }], + }, + { + name: 'Event', + knowledgeGraphId: null, + properties: [ + { name: 'speaker', dataType: 'Relation(Account)', relationType: 'Account', knowledgeGraphId: null }, + ], + }, + ]; + + expect(allRelationPropertyTypesExist(types)).toEqual(true); + }); + it('should return false if the submitted schema relation properties', () => { + const types: Schema['types'] = [ + { + name: 'Event', + knowledgeGraphId: null, + properties: [ + { name: 'speaker', dataType: 'Relation(Account)', relationType: 'Account', knowledgeGraphId: null }, + ], + }, + ]; + + expect(allRelationPropertyTypesExist(types)).toEqual(false); + }); + }); + + describe('generateMapping', () => { + it('should be able to map the input schema to a resulting Mapping definition', () => { + const [mapping] = generateMapping({ + types: [ + { + name: 'Account', + knowledgeGraphId: null, + properties: [ + { + name: 'username', + dataType: 'Text', + knowledgeGraphId: null, + }, + { + name: 'createdAt', + dataType: 'Date', + knowledgeGraphId: null, + }, + ], + }, + { + name: 'Event', + knowledgeGraphId: null, + properties: [ + { + name: 'name', + dataType: 'Text', + knowledgeGraphId: null, + }, + { + name: 'description', + dataType: 'Text', + knowledgeGraphId: null, + }, + { + name: 'speaker', + dataType: 'Relation(Account)', + relationType: 'Account', + knowledgeGraphId: null, + }, + ], + }, + ], + }); + const expected: Mapping = { + Account: { + typeIds: [expect.any(String)], + properties: { + username: expect.any(String), + createdAt: expect.any(String), + }, + }, + Event: { + typeIds: [expect.any(String)], + properties: { + name: expect.any(String), + description: expect.any(String), + }, + relations: { + speaker: expect.any(String), + }, + }, + }; + + expect(mapping).toEqual(expected); + }); + it('should use the existing KG ids if provided', () => { + const [mapping] = generateMapping({ + types: [ + { + name: 'Account', + knowledgeGraphId: 'a5fd07b1-120f-46c6-b46f-387ef98396a6', + properties: [ + { + name: 'username', + dataType: 'Text', + knowledgeGraphId: '994edcff-6996-4a77-9797-a13e5e3efad8', + }, + { + name: 'createdAt', + dataType: 'Date', + knowledgeGraphId: '64bfba51-a69b-4746-be4b-213214a879fe', + }, + ], + }, + { + name: 'Event', + knowledgeGraphId: null, + properties: [ + { + name: 'name', + dataType: 'Text', + knowledgeGraphId: '3808e060-fb4a-4d08-8069-35b8c8a1902b', + }, + { + name: 'description', + dataType: 'Text', + knowledgeGraphId: null, + }, + { + name: 'speaker', + dataType: 'Relation(Account)', + relationType: 'Account', + knowledgeGraphId: null, + }, + ], + }, + ], + }); + const expected: Mapping = { + Account: { + typeIds: [Id.Id('a5fd07b1-120f-46c6-b46f-387ef98396a6')], + properties: { + username: Id.Id('994edcff-6996-4a77-9797-a13e5e3efad8'), + createdAt: Id.Id('64bfba51-a69b-4746-be4b-213214a879fe'), + }, + }, + Event: { + typeIds: [expect.any(String)], + properties: { + name: Id.Id('3808e060-fb4a-4d08-8069-35b8c8a1902b'), + description: expect.any(String), + }, + relations: { + speaker: expect.any(String), + }, + }, + }; + + expect(mapping).toEqual(expected); + }); + it('should handle relation properties where the related type has a knowledgeGraphId', () => { + const [mapping] = generateMapping({ + types: [ + { + name: 'Account', + knowledgeGraphId: 'a5fd07b1-120f-46c6-b46f-387ef98396a6', + properties: [ + { + name: 'username', + dataType: 'Text', + knowledgeGraphId: null, + }, + ], + }, + { + name: 'Event', + knowledgeGraphId: null, + properties: [ + { + name: 'name', + dataType: 'Text', + knowledgeGraphId: null, + }, + { + name: 'organizer', + dataType: 'Relation(Account)', + relationType: 'Account', + knowledgeGraphId: null, + }, + ], + }, + ], + }); + const expected: Mapping = { + Account: { + typeIds: [Id.Id('a5fd07b1-120f-46c6-b46f-387ef98396a6')], + properties: { + username: expect.any(String), + }, + }, + Event: { + typeIds: [expect.any(String)], + properties: { + name: expect.any(String), + }, + relations: { + organizer: expect.any(String), + }, + }, + }; + + expect(mapping).toEqual(expected); + }); + it('should handle relation properties where the related type does NOT have a knowledgeGraphId (second pass)', () => { + const [mapping] = generateMapping({ + types: [ + { + name: 'Account', + knowledgeGraphId: null, + properties: [ + { + name: 'username', + dataType: 'Text', + knowledgeGraphId: null, + }, + ], + }, + { + name: 'Event', + knowledgeGraphId: null, + properties: [ + { + name: 'name', + dataType: 'Text', + knowledgeGraphId: null, + }, + { + name: 'organizer', + dataType: 'Relation(Account)', + relationType: 'Account', + knowledgeGraphId: null, + }, + ], + }, + ], + }); + const expected: Mapping = { + Account: { + typeIds: [expect.any(String)], + properties: { + username: expect.any(String), + }, + }, + Event: { + typeIds: [expect.any(String)], + properties: { + name: expect.any(String), + }, + relations: { + organizer: expect.any(String), + }, + }, + }; + + expect(mapping).toEqual(expected); + }); + it('should handle mixed scenarios with some relation types having knowledgeGraphId and others not', () => { + const [mapping] = generateMapping({ + types: [ + { + name: 'Account', + knowledgeGraphId: 'a5fd07b1-120f-46c6-b46f-387ef98396a6', + properties: [ + { + name: 'username', + dataType: 'Text', + knowledgeGraphId: '994edcff-6996-4a77-9797-a13e5e3efad8', + }, + ], + }, + { + name: 'Venue', + knowledgeGraphId: null, + properties: [ + { + name: 'name', + dataType: 'Text', + knowledgeGraphId: null, + }, + { + name: 'location', + dataType: 'Point', + knowledgeGraphId: null, + }, + ], + }, + { + name: 'Event', + knowledgeGraphId: null, + properties: [ + { + name: 'title', + dataType: 'Text', + knowledgeGraphId: null, + }, + { + name: 'speaker', + dataType: 'Relation(Account)', + relationType: 'Account', + knowledgeGraphId: null, + }, + { + name: 'venue', + dataType: 'Relation(Venue)', + relationType: 'Venue', + knowledgeGraphId: null, + }, + ], + }, + ], + }); + const expected: Mapping = { + Account: { + typeIds: [Id.Id('a5fd07b1-120f-46c6-b46f-387ef98396a6')], + properties: { + username: Id.Id('994edcff-6996-4a77-9797-a13e5e3efad8'), + }, + }, + Venue: { + typeIds: [expect.any(String)], + properties: { + name: expect.any(String), + location: expect.any(String), + }, + }, + Event: { + typeIds: [expect.any(String)], + properties: { + title: expect.any(String), + }, + relations: { + speaker: expect.any(String), + venue: expect.any(String), + }, + }, + }; + + expect(mapping).toEqual(expected); + }); + describe('schema validation failures', () => { + it('should throw an error if the Schema does not pass validation: type names are not unique', () => { + expect(() => + generateMapping({ + types: [ + { + name: 'Account', + knowledgeGraphId: null, + properties: [{ name: 'username', dataType: 'Text', knowledgeGraphId: null }], + }, + { + name: 'Account', + knowledgeGraphId: null, + properties: [{ name: 'image', dataType: 'Text', knowledgeGraphId: null }], + }, + ], + }), + ).toThrowError(); + }); + it('should throw an error if the Schema does not pass validation: type property names are not unique', () => { + expect(() => + generateMapping({ + types: [ + { + name: 'Account', + knowledgeGraphId: null, + properties: [ + { name: 'username', dataType: 'Text', knowledgeGraphId: null }, + { name: 'username', dataType: 'Text', knowledgeGraphId: null }, + ], + }, + ], + }), + ).toThrowError(); + }); + it('should throw an error if the Schema does not pass validation: referenced relation property does not have matching type in schema', () => { + expect(() => + generateMapping({ + types: [ + { + name: 'Event', + knowledgeGraphId: null, + properties: [ + { name: 'speaker', dataType: 'Relation(Account)', relationType: 'Account', knowledgeGraphId: null }, + ], + }, + ], + }), + ).toThrowError(); + }); + }); + }); +}); diff --git a/packages/hypergraph/test/mapping/Utils.test.ts b/packages/hypergraph/test/mapping/Utils.test.ts new file mode 100644 index 00000000..813cc96c --- /dev/null +++ b/packages/hypergraph/test/mapping/Utils.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, it } from 'vitest'; + +import * as Utils from '../../src/mapping/Utils.js'; + +describe('Utils', () => { + describe('toCamelCase', () => { + it('should convert the strings to camelCase', () => { + expect(Utils.toCamelCase('Address line 1')).toEqual('addressLine1'); + expect(Utils.toCamelCase('AddressLine1')).toEqual('addressLine1'); + expect(Utils.toCamelCase('addressLine1')).toEqual('addressLine1'); + expect(Utils.toCamelCase('address_line_1')).toEqual('addressLine1'); + expect(Utils.toCamelCase('address-line-1')).toEqual('addressLine1'); + expect(Utils.toCamelCase('address-line_1')).toEqual('addressLine1'); + expect(Utils.toCamelCase('address-line 1')).toEqual('addressLine1'); + expect(Utils.toCamelCase('ADDRESS_LINE_1')).toEqual('addressLine1'); + }); + it.fails('should throw an InvalidNameError if string is empty', () => { + expect(Utils.toCamelCase('')).toThrowError(Utils.InvalidInputError); + }); + }); + describe('toPascalCase', () => { + it('should convert the strings to PascalCase', () => { + expect(Utils.toPascalCase('Address line 1')).toEqual('AddressLine1'); + expect(Utils.toPascalCase('AddressLine1')).toEqual('AddressLine1'); + expect(Utils.toPascalCase('addressLine1')).toEqual('AddressLine1'); + expect(Utils.toPascalCase('address_line_1')).toEqual('AddressLine1'); + expect(Utils.toPascalCase('address-line-1')).toEqual('AddressLine1'); + expect(Utils.toPascalCase('address-line_1')).toEqual('AddressLine1'); + expect(Utils.toPascalCase('address-line 1')).toEqual('AddressLine1'); + expect(Utils.toPascalCase('ADDRESS_LINE_1')).toEqual('AddressLine1'); + }); + it.fails('should throw an InvalidNameError if string is empty', () => { + expect(Utils.toPascalCase('')).toThrowError(Utils.InvalidInputError); + }); + }); + describe('namesAreUnique', () => { + it('should return true if the name prop on each entry is unique', () => { + expect(Utils.namesAreUnique([{ name: 'Account' }, { name: 'Event' }])).toEqual(true); + }); + it('should return false if the name prop on each entry is not unique', () => { + expect(Utils.namesAreUnique([{ name: 'Account' }, { name: 'Event' }, { name: 'Account' }])).toEqual(false); + // should handle casing + expect(Utils.namesAreUnique([{ name: 'Account' }, { name: 'Event' }, { name: 'account' }])).toEqual(false); + }); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bb5703e3..933c89bb 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -369,9 +369,6 @@ importers: '@graphprotocol/hypergraph-react': specifier: workspace:* version: link:../../packages/hypergraph-react/publish - '@graphprotocol/typesync': - specifier: workspace:* - version: link:../../packages/typesync/publish '@noble/hashes': specifier: ^1.8.0 version: 1.8.0 @@ -886,9 +883,6 @@ importers: specifier: ^2.30.6 version: 2.30.6(bufferutil@4.0.9)(typescript@5.8.3)(utf-8-validate@5.0.10)(zod@3.25.51) devDependencies: - '@graphprotocol/typesync': - specifier: workspace:* - version: link:../typesync/publish '@types/uuid': specifier: ^10.0.0 version: 10.0.0 @@ -933,9 +927,6 @@ importers: '@graphprotocol/hypergraph': specifier: workspace:* version: link:../hypergraph/publish - '@graphprotocol/typesync': - specifier: workspace:* - version: link:../typesync/publish '@testing-library/jest-dom': specifier: ^6.6.3 version: 6.6.3 From 1411a52d9da1aac2b62275eb22c93d1a1c91d4c6 Mon Sep 17 00:00:00 2001 From: Chris Whited Date: Mon, 28 Jul 2025 10:09:46 -1000 Subject: [PATCH 2/3] fix(#392 | move typesync api): perform changeset version --- .changeset/tired-wasps-matter.md | 21 --------------------- apps/connect/CHANGELOG.md | 7 +++++++ apps/connect/package.json | 2 +- apps/server/CHANGELOG.md | 6 ++++++ apps/server/package.json | 2 +- packages/hypergraph-react/CHANGELOG.md | 25 +++++++++++++++++++++++++ packages/hypergraph-react/package.json | 2 +- packages/hypergraph/CHANGELOG.md | 20 ++++++++++++++++++++ packages/hypergraph/package.json | 2 +- 9 files changed, 62 insertions(+), 25 deletions(-) delete mode 100644 .changeset/tired-wasps-matter.md diff --git a/.changeset/tired-wasps-matter.md b/.changeset/tired-wasps-matter.md deleted file mode 100644 index b538ac28..00000000 --- a/.changeset/tired-wasps-matter.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -"@graphprotocol/hypergraph-react": minor -"@graphprotocol/hypergraph": minor ---- - -Move @graphprotocol/typesync Mapping and Utils into @graphprotocol/hypergraph package and export from there. Update @graphprotocol/hypergraph-react to use mapping from @graphprotocol/hypergraph. - - -## Changes needed - -Any use of `@graphprotocol/typesync` should use the exported mapping and utils from `@graphprotocol/hypergraph` instead. - -### Example - -```ts -// before -import type { Mapping } from '@graphprotocol/typesync/Mapping' - -// after -import type { Mapping } from '@graphprotocol/hypergraph/mapping' -``` \ No newline at end of file diff --git a/apps/connect/CHANGELOG.md b/apps/connect/CHANGELOG.md index 67a289d0..56f62d61 100644 --- a/apps/connect/CHANGELOG.md +++ b/apps/connect/CHANGELOG.md @@ -1,5 +1,12 @@ # connect +## 0.1.1 +### Patch Changes + +- Updated dependencies [8622688] + - @graphprotocol/hypergraph-react@1.0.0 + - @graphprotocol/hypergraph@0.2.0 + ## 0.1.0 ### Patch Changes diff --git a/apps/connect/package.json b/apps/connect/package.json index 6c5087a2..c9d254cc 100644 --- a/apps/connect/package.json +++ b/apps/connect/package.json @@ -1,7 +1,7 @@ { "name": "connect", "private": true, - "version": "0.1.0", + "version": "0.1.1", "type": "module", "scripts": { "dev": "vite --force", diff --git a/apps/server/CHANGELOG.md b/apps/server/CHANGELOG.md index c84ec561..5e1cf4a3 100644 --- a/apps/server/CHANGELOG.md +++ b/apps/server/CHANGELOG.md @@ -1,5 +1,11 @@ # server +## 0.1.1 +### Patch Changes + +- Updated dependencies [8622688] + - @graphprotocol/hypergraph@0.2.0 + ## 0.1.0 ### Patch Changes diff --git a/apps/server/package.json b/apps/server/package.json index f84b6418..6fc25561 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -1,6 +1,6 @@ { "name": "server", - "version": "0.1.0", + "version": "0.1.1", "private": true, "type": "module", "scripts": { diff --git a/packages/hypergraph-react/CHANGELOG.md b/packages/hypergraph-react/CHANGELOG.md index 21efe93a..2ee9cfdd 100644 --- a/packages/hypergraph-react/CHANGELOG.md +++ b/packages/hypergraph-react/CHANGELOG.md @@ -1,5 +1,30 @@ # @graphprotocol/hypergraph-react +## 1.0.0 +### Minor Changes + +- 8622688: Move @graphprotocol/typesync Mapping and Utils into @graphprotocol/hypergraph package and export from there. Update @graphprotocol/hypergraph-react to use mapping from @graphprotocol/hypergraph. + + + ## Changes needed + + Any use of `@graphprotocol/typesync` should use the exported mapping and utils from `@graphprotocol/hypergraph` instead. + + ### Example + + ```ts + // before + import type { Mapping } from '@graphprotocol/typesync/Mapping' + + // after + import type { Mapping } from '@graphprotocol/hypergraph/mapping' + ``` + +### Patch Changes + +- Updated dependencies [8622688] + - @graphprotocol/hypergraph@0.2.0 + ## 0.1.0 ### Patch Changes diff --git a/packages/hypergraph-react/package.json b/packages/hypergraph-react/package.json index 7b806580..c4bf62b1 100644 --- a/packages/hypergraph-react/package.json +++ b/packages/hypergraph-react/package.json @@ -1,6 +1,6 @@ { "name": "@graphprotocol/hypergraph-react", - "version": "0.1.0", + "version": "1.0.0", "description": "React implementation and additional functionality, components, and hooks for the hypergraph SDK framework", "keywords": [ "Web3", diff --git a/packages/hypergraph/CHANGELOG.md b/packages/hypergraph/CHANGELOG.md index 4d1ef01f..47e39f3b 100644 --- a/packages/hypergraph/CHANGELOG.md +++ b/packages/hypergraph/CHANGELOG.md @@ -1,5 +1,25 @@ # @graphprotocol/hypergraph +## 0.2.0 +### Minor Changes + +- 8622688: Move @graphprotocol/typesync Mapping and Utils into @graphprotocol/hypergraph package and export from there. Update @graphprotocol/hypergraph-react to use mapping from @graphprotocol/hypergraph. + + + ## Changes needed + + Any use of `@graphprotocol/typesync` should use the exported mapping and utils from `@graphprotocol/hypergraph` instead. + + ### Example + + ```ts + // before + import type { Mapping } from '@graphprotocol/typesync/Mapping' + + // after + import type { Mapping } from '@graphprotocol/hypergraph/mapping' + ``` + ## 0.1.0 ### Patch Changes diff --git a/packages/hypergraph/package.json b/packages/hypergraph/package.json index 7e38c31e..8d2a8504 100644 --- a/packages/hypergraph/package.json +++ b/packages/hypergraph/package.json @@ -1,6 +1,6 @@ { "name": "@graphprotocol/hypergraph", - "version": "0.1.0", + "version": "0.2.0", "description": "SDK for building performant, type-safe, local-first dapps on top of The Graph ecosystem knowledge graphs.", "publishConfig": { "access": "public", From 9555aad45522795f85f5159df1654c50a034c6f2 Mon Sep 17 00:00:00 2001 From: Chris Whited Date: Mon, 28 Jul 2025 11:04:51 -1000 Subject: [PATCH 3/3] fix(#392 | move typesync api): use root hypergraph export --- apps/events/src/mapping.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/events/src/mapping.ts b/apps/events/src/mapping.ts index 7d138bee..a2502f0e 100644 --- a/apps/events/src/mapping.ts +++ b/apps/events/src/mapping.ts @@ -1,7 +1,7 @@ import { Id } from '@graphprotocol/grc-20'; -import type { Mapping } from '@graphprotocol/hypergraph/mapping'; +import type { Mapping } from '@graphprotocol/hypergraph'; -export const mapping: Mapping = { +export const mapping: Mapping.Mapping = { Event: { typeIds: [Id.Id('7f9562d4-034d-4385-bf5c-f02cdebba47a')], properties: {