From c7a0999974c79d98a2a7a0e9dd217c04a7dd6369 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Fri, 25 Jul 2025 06:55:44 +0200 Subject: [PATCH 01/11] add Type.optional --- packages/hypergraph/src/type/type.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/hypergraph/src/type/type.ts b/packages/hypergraph/src/type/type.ts index 9e14a3ee..d354d5cb 100644 --- a/packages/hypergraph/src/type/type.ts +++ b/packages/hypergraph/src/type/type.ts @@ -16,6 +16,8 @@ export const Point = Schema.transform(Schema.String, Schema.Array(Number), { encode: (points: readonly number[]) => points.join(','), }); +export const optional = Schema.optional; + export const Relation = (schema: S) => { const relationSchema = Field({ select: Schema.Array(schema) as unknown as Schema.Schema>>, From 77837c7525d54af9773f7cda2bd52bd542886e90 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Fri, 25 Jul 2025 07:04:40 +0200 Subject: [PATCH 02/11] remove unused code --- .../src/internal/use-generate-update-ops.tsx | 92 --------- packages/hypergraph-react/src/use-query.tsx | 179 +----------------- 2 files changed, 1 insertion(+), 270 deletions(-) delete mode 100644 packages/hypergraph-react/src/internal/use-generate-update-ops.tsx diff --git a/packages/hypergraph-react/src/internal/use-generate-update-ops.tsx b/packages/hypergraph-react/src/internal/use-generate-update-ops.tsx deleted file mode 100644 index cdb3bc2b..00000000 --- a/packages/hypergraph-react/src/internal/use-generate-update-ops.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import type { Op } from '@graphprotocol/grc-20'; -import { type Entity, store } from '@graphprotocol/hypergraph'; -import { useSelector } from '@xstate/store/react'; -import type { DiffEntry } from '../types.js'; - -export function useGenerateUpdateOps(type: S, enabled = true) { - const mapping = useSelector(store, (state) => state.context.mapping); - - // biome-ignore lint/correctness/noUnusedFunctionParameters: temporary - return ({ id, diff }: { id: string; diff: DiffEntry }) => { - // @ts-expect-error TODO should use the actual type instead of the name in the mapping - const typeName = type.name; - const mappingEntry = mapping?.[typeName]; - if (!mappingEntry && enabled) { - throw new Error(`Mapping entry for ${typeName} not found`); - } - - if (!enabled || !mappingEntry || !mappingEntry.properties) { - return { ops: [] }; - } - const ops: Op[] = []; - - // for (const [key, propertyId] of Object.entries(mappingEntry.properties || {})) { - // if (diff[key] === undefined) { - // continue; - // } - // const propertyDiff = diff[key]; - // if (propertyDiff === undefined || propertyDiff.type === 'relation') { - // throw new Error(`Invalid diff or mapping for generating update Ops on the property \`${key}\``); - // } - - // const rawValue = propertyDiff.new; - - // let value: Value; - // if (type.fields[key] === Type.Checkbox) { - // value = { - // type: 'CHECKBOX', - // value: rawValue ? '1' : '0', - // }; - // } else if (type.fields[key] === Type.Point) { - // value = { - // type: 'POINT', - // // @ts-expect-error: must be an array of numbers - // value: rawValue.join(','), - // }; - // } else if (type.fields[key] === Type.Date) { - // value = { - // type: 'TIME', - // // @ts-expect-error: must be a Date - // value: rawValue.toISOString(), - // }; - // } else if (type.fields[key] === Type.Number) { - // value = { - // type: 'NUMBER', - // // @ts-expect-error: must be a number - // value: rawValue.toString(), - // }; - // } else { - // value = { - // type: 'TEXT', - // value: rawValue as string, - // }; - // } - // const op = Triple.make({ - // attributeId: propertyId, - // entityId: id, - // value, - // }); - // ops.push(op); - // } - - // for (const [key, relationId] of Object.entries(mappingEntry.relations || {})) { - // const relationDiff = diff[key]; - // if (!relationDiff) { - // continue; - // } - // if (relationDiff.type === 'property') { - // throw new Error(`Invalid diff or mapping for generating update Ops on the relation \`${key}\``); - // } - // for (const toId of relationDiff.addedIds) { - // const op = Relation.make({ - // fromId: Id.Id(id), - // toId: Id.Id(toId), - // relationTypeId: relationId, - // }); - // ops.push(op); - // } - // } - - return { ops }; - }; -} diff --git a/packages/hypergraph-react/src/use-query.tsx b/packages/hypergraph-react/src/use-query.tsx index 40e85ffa..2b14abf3 100644 --- a/packages/hypergraph-react/src/use-query.tsx +++ b/packages/hypergraph-react/src/use-query.tsx @@ -1,8 +1,7 @@ -import { type Entity, Type, Utils } from '@graphprotocol/hypergraph'; +import type { Entity } from '@graphprotocol/hypergraph'; import type * as Schema from 'effect/Schema'; import { useQueryLocal } from './HypergraphSpaceContext.js'; import { useQueryPublic } from './internal/use-query-public.js'; -import type { DiffEntry } from './types.js'; type QueryParams = { mode: 'public' | 'private'; @@ -13,142 +12,12 @@ type QueryParams = { first?: number | undefined; }; -// @ts-expect-error TODO: remove this function -const _mergeEntities = ( - publicEntities: Entity.Entity[], - localEntities: Entity.Entity[], - localDeletedEntities: Entity.Entity[], -) => { - const mergedData: Entity.Entity[] = []; - - for (const entity of publicEntities) { - const deletedEntity = localDeletedEntities.find((e) => e.id === entity.id); - if (deletedEntity) { - continue; - } - const localEntity = localEntities.find((e) => e.id === entity.id); - if (localEntity) { - const mergedEntity = { ...entity }; - for (const key in entity) { - mergedEntity[key] = localEntity[key]; - } - mergedData.push(mergedEntity); - } else { - mergedData.push(entity); - } - } - - // find all local entities that are not in the public result - const localEntitiesNotInPublic = localEntities.filter((e) => !publicEntities.some((p) => p.id === e.id)); - - mergedData.push(...localEntitiesNotInPublic); - return mergedData; -}; - -// @ts-expect-error TODO: remove this function -const _getDiff = ( - type: S, - publicEntities: Entity.Entity[], - localEntities: Entity.Entity[], - localDeletedEntities: Entity.Entity[], -) => { - const deletedEntities: Entity.Entity[] = []; - const updatedEntities: { id: string; current: Entity.Entity; new: Entity.Entity; diff: DiffEntry }[] = []; - - for (const entity of publicEntities) { - const deletedEntity = localDeletedEntities.find((e) => e.id === entity.id); - if (deletedEntity) { - deletedEntities.push(deletedEntity); - continue; - } - const localEntity = localEntities.find((e) => e.id === entity.id); - if (localEntity) { - const diff: DiffEntry = {}; - - for (const [key, field] of Object.entries(type.fields)) { - if (key === '__version' || key === '__deleted') { - continue; - } - - if (Utils.isRelationField(field)) { - const relationIds: string[] = entity[key].map((e: Entity.Entity) => e.id); - const localRelationIds: string[] = localEntity[key].map((e: Entity.Entity) => e.id); - if ( - relationIds.length !== localRelationIds.length || - relationIds.some((id) => !localRelationIds.includes(id)) - ) { - const removedIds = relationIds.filter((id) => !localRelationIds.includes(id)); - const addedIds = localRelationIds.filter((id) => !relationIds.includes(id)); - // get a list of the ids that didn't get added or removed - const unchangedIds = localRelationIds.filter((id) => !addedIds.includes(id) && !removedIds.includes(id)); - diff[key] = { - type: 'relation', - current: entity[key], - new: localEntity[key], - addedIds, - removedIds, - unchangedIds, - }; - } - } else { - if (field === Type.Date) { - if (entity[key].getTime() !== localEntity[key].getTime()) { - diff[key] = { - type: 'property', - current: entity[key], - new: localEntity[key], - }; - } - } else if (field === Type.Point) { - if (entity[key].join(',') !== localEntity[key].join(',')) { - diff[key] = { - type: 'property', - current: entity[key], - new: localEntity[key], - }; - } - } else if (entity[key] !== localEntity[key]) { - diff[key] = { - type: 'property', - current: entity[key], - new: localEntity[key], - }; - } - } - } - - if (Object.keys(diff).length > 0) { - updatedEntities.push({ id: entity.id, current: entity, new: localEntity, diff }); - } - } else { - // TODO update the local entity in this place? - } - } - - const newEntities = localEntities.filter((e) => !publicEntities.some((p) => p.id === e.id)); - - return { - newEntities, - deletedEntities, - updatedEntities, - }; -}; - const preparePublishDummy = () => undefined; export function useQuery(type: S, params: QueryParams) { const { mode, filter, include, space, first } = params; const publicResult = useQueryPublic(type, { enabled: mode === 'public', include, first, space }); const localResult = useQueryLocal(type, { enabled: mode === 'private', filter, include, space }); - // const mapping = useSelector(store, (state) => state.context.mapping); - // const generateUpdateOps = useGenerateUpdateOps(type, mode === 'merged'); - - // const mergedData = useMemo(() => { - // if (mode !== 'merged' || publicResult.isLoading) { - // return localResult.entities; - // } - // return mergeEntities(publicResult.data, localResult.entities, localResult.deletedEntities); - // }, [mode, publicResult.isLoading, publicResult.data, localResult.entities, localResult.deletedEntities]); if (mode === 'public') { return { @@ -164,50 +33,4 @@ export function useQuery(type: S, params: Q deleted: localResult.deletedEntities, preparePublish: preparePublishDummy, }; - - // const preparePublish = async (): Promise => { - // // @ts-expect-error TODO should use the actual type instead of the name in the mapping - // const typeName = type.name; - // const mappingEntry = mapping?.[typeName]; - // if (!mappingEntry) { - // throw new Error(`Mapping entry for ${typeName} not found`); - // } - - // const result = await publicResult.refetch(); - // if (!result.data) { - // throw new Error('No data found'); - // } - // const diff = getDiff( - // type, - // parseResult(result.data, type, mappingEntry, mapping).data, - // localResult.entities, - // localResult.deletedEntities, - // ); - - // const newEntities = diff.newEntities.map((entity) => { - // const { ops: createOps } = generateCreateOps(entity); - // return { id: entity.id, entity, ops: createOps }; - // }); - - // const updatedEntities = diff.updatedEntities.map((updatedEntityInfo) => { - // const { ops: updateOps } = generateUpdateOps({ id: updatedEntityInfo.id, diff: updatedEntityInfo.diff }); - // return { ...updatedEntityInfo, ops: updateOps }; - // }); - - // const deletedEntities = await Promise.all( - // diff.deletedEntities.map(async (entity) => { - // const deleteOps = await generateDeleteOps(entity); - // return { id: entity.id, entity, ops: deleteOps }; - // }), - // ); - - // return { newEntities, updatedEntities, deletedEntities }; - // }; - - // return { - // ...publicResult, - // data: mergedData, - // deleted: localResult.deletedEntities, - // preparePublish: !publicResult.isLoading ? preparePublish : preparePublishDummy, - // }; } From d37cdc3c2a51322e40208dfa8d8c693855135c4c Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Fri, 25 Jul 2025 15:05:15 +0200 Subject: [PATCH 03/11] implement Type.optional --- apps/events/package.json | 1 + apps/events/src/components/events/events.tsx | 18 +++++++- apps/events/src/components/ui/label.tsx | 19 ++++++++ apps/events/src/mapping.ts | 1 + apps/events/src/schema.ts | 2 +- .../client/src/Components/App/Schema/utils.ts | 1 + apps/typesync/src/Generator.ts | 1 + docs/docs/schema.md | 2 + .../src/internal/use-create-entity-public.ts | 16 +++++-- .../src/internal/use-query-public.tsx | 10 ++-- .../hypergraph-react/src/prepare-publish.ts | 30 ++++++++---- packages/hypergraph/src/index.ts | 1 + .../hypergraph/src/type-utils/type-utils.ts | 46 +++++++++++++++++++ pnpm-lock.yaml | 43 ++++++++++++++++- 14 files changed, 168 insertions(+), 23 deletions(-) create mode 100644 apps/events/src/components/ui/label.tsx create mode 100644 packages/hypergraph/src/type-utils/type-utils.ts diff --git a/apps/events/package.json b/apps/events/package.json index 496f15fd..ddd19790 100644 --- a/apps/events/package.json +++ b/apps/events/package.json @@ -14,6 +14,7 @@ "@noble/hashes": "^1.8.0", "@radix-ui/react-avatar": "^1.1.9", "@radix-ui/react-icons": "^1.3.2", + "@radix-ui/react-label": "^2.1.7", "@radix-ui/react-slot": "^1.2.2", "@tanstack/react-query": "^5.75.5", "@tanstack/react-router": "^1.120.2", diff --git a/apps/events/src/components/events/events.tsx b/apps/events/src/components/events/events.tsx index dbc4cef3..90ecc163 100644 --- a/apps/events/src/components/events/events.tsx +++ b/apps/events/src/components/events/events.tsx @@ -10,6 +10,7 @@ import { useState } from 'react'; import { Event } from '../../schema'; import { Button } from '../ui/button'; import { Input } from '../ui/input'; +import { Label } from '../ui/label'; export const Events = () => { const { data: eventsLocalData } = useQuery(Event, { mode: 'private' }); @@ -43,6 +44,7 @@ export const Events = () => { {eventsLocalData.map((event) => (

{event.name}

+

{event.description}

{event.id}
+ + + + diff --git a/apps/events/src/components/ui/label.tsx b/apps/events/src/components/ui/label.tsx new file mode 100644 index 00000000..713b8db6 --- /dev/null +++ b/apps/events/src/components/ui/label.tsx @@ -0,0 +1,19 @@ +import * as LabelPrimitive from '@radix-ui/react-label'; +import type * as React from 'react'; + +import { cn } from '@/lib/utils'; + +function Label({ className, ...props }: React.ComponentProps) { + return ( + + ); +} + +export { Label }; diff --git a/apps/events/src/mapping.ts b/apps/events/src/mapping.ts index a2502f0e..6ff447f9 100644 --- a/apps/events/src/mapping.ts +++ b/apps/events/src/mapping.ts @@ -6,6 +6,7 @@ export const mapping: Mapping.Mapping = { typeIds: [Id.Id('7f9562d4-034d-4385-bf5c-f02cdebba47a')], properties: { name: Id.Id('a126ca53-0c8e-48d5-b888-82c734c38935'), + description: Id.Id('9b1f76ff-9711-404c-861e-59dc3fa7d037'), }, relations: { sponsors: Id.Id('6860bfac-f703-4289-b789-972d0aaf3abe'), diff --git a/apps/events/src/schema.ts b/apps/events/src/schema.ts index ccf61ae3..a751eadb 100644 --- a/apps/events/src/schema.ts +++ b/apps/events/src/schema.ts @@ -33,6 +33,6 @@ export class Company extends Entity.Class('Company')({ export class Event extends Entity.Class('Event')({ name: Type.Text, - // description: Type.Text, + description: Type.optional(Type.Text), sponsors: Type.Relation(Company), }) {} diff --git a/apps/typesync/client/src/Components/App/Schema/utils.ts b/apps/typesync/client/src/Components/App/Schema/utils.ts index 52d85fa4..8be83f18 100644 --- a/apps/typesync/client/src/Components/App/Schema/utils.ts +++ b/apps/typesync/client/src/Components/App/Schema/utils.ts @@ -1,5 +1,6 @@ import type { AppSchema } from '../../../schema.js'; +// TODO function fieldToEntityString({ name, type_name, diff --git a/apps/typesync/src/Generator.ts b/apps/typesync/src/Generator.ts index ecb2bd36..c6b4e94d 100644 --- a/apps/typesync/src/Generator.ts +++ b/apps/typesync/src/Generator.ts @@ -301,6 +301,7 @@ function validatePackageName(name: string): { }; } +// TODO // -------------------- // schema builder // -------------------- diff --git a/docs/docs/schema.md b/docs/docs/schema.md index c3623698..412965e7 100644 --- a/docs/docs/schema.md +++ b/docs/docs/schema.md @@ -2,6 +2,8 @@ The Hypergraph schema allows you to define the data model for your application. It is based on the GRC-20 specification and allows you to define Types with properties and relations to other Types. +TODO update this doc + ## Example Here is an example of a schema for an Event app with the properties `name` and `description`. diff --git a/packages/hypergraph-react/src/internal/use-create-entity-public.ts b/packages/hypergraph-react/src/internal/use-create-entity-public.ts index 130ba57a..fda8edd4 100644 --- a/packages/hypergraph-react/src/internal/use-create-entity-public.ts +++ b/packages/hypergraph-react/src/internal/use-create-entity-public.ts @@ -1,6 +1,6 @@ import { Graph, Id, type PropertiesParam, type RelationsParam } from '@graphprotocol/grc-20'; import type { Connect, Entity } from '@graphprotocol/hypergraph'; -import { store, Type } from '@graphprotocol/hypergraph'; +import { store, TypeUtils } from '@graphprotocol/hypergraph'; import { useQueryClient } from '@tanstack/react-query'; import { useSelector } from '@xstate/store/react'; import type * as Schema from 'effect/Schema'; @@ -33,14 +33,20 @@ export function useCreateEntityPublic( const fields = type.fields; const values: PropertiesParam = []; for (const [key, value] of Object.entries(mappingEntry.properties || {})) { + if (data[key] === undefined) { + if (TypeUtils.isOptional(fields[key])) { + continue; + } + throw new Error(`Value for ${key} is undefined`); + } let serializedValue: string = data[key]; - if (fields[key] === Type.Checkbox) { + if (TypeUtils.isCheckboxOrOptionalCheckboxType(fields[key])) { serializedValue = Graph.serializeCheckbox(data[key]); - } else if (fields[key] === Type.Date) { + } else if (TypeUtils.isDateOrOptionalDateType(fields[key])) { serializedValue = Graph.serializeDate(data[key]); - } else if (fields[key] === Type.Point) { + } else if (TypeUtils.isPointOrOptionalPointType(fields[key])) { serializedValue = Graph.serializePoint(data[key]); - } else if (fields[key] === Type.Number) { + } else if (TypeUtils.isNumberOrOptionalNumberType(fields[key])) { serializedValue = Graph.serializeNumber(data[key]); } diff --git a/packages/hypergraph-react/src/internal/use-query-public.tsx b/packages/hypergraph-react/src/internal/use-query-public.tsx index a976bd64..4cad8f53 100644 --- a/packages/hypergraph-react/src/internal/use-query-public.tsx +++ b/packages/hypergraph-react/src/internal/use-query-public.tsx @@ -1,5 +1,5 @@ import { Graph } from '@graphprotocol/grc-20'; -import { type Entity, type Mapping, store, Type } from '@graphprotocol/hypergraph'; +import { type Entity, type Mapping, store, TypeUtils } from '@graphprotocol/hypergraph'; import { useQuery as useQueryTanstack } from '@tanstack/react-query'; import { useSelector } from '@xstate/store/react'; import * as Either from 'effect/Either'; @@ -153,16 +153,16 @@ const convertPropertyValue = ( key: string, type: Entity.AnyNoContext, ) => { - if (type.fields[key] === Type.Checkbox) { + if (TypeUtils.isCheckboxOrOptionalCheckboxType(type.fields[key]) && property.value !== undefined) { return Boolean(property.value); } - if (type.fields[key] === Type.Point) { + if (TypeUtils.isPointOrOptionalPointType(type.fields[key]) && property.value !== undefined) { return property.value; } - if (type.fields[key] === Type.Date) { + if (TypeUtils.isDateOrOptionalDateType(type.fields[key]) && property.value !== undefined) { return property.value; } - if (type.fields[key] === Type.Number) { + if (TypeUtils.isNumberOrOptionalNumberType(type.fields[key]) && property.value !== undefined) { return Number(property.value); } return property.value; diff --git a/packages/hypergraph-react/src/prepare-publish.ts b/packages/hypergraph-react/src/prepare-publish.ts index dd742bfe..fc99d663 100644 --- a/packages/hypergraph-react/src/prepare-publish.ts +++ b/packages/hypergraph-react/src/prepare-publish.ts @@ -7,7 +7,7 @@ import { type RelationsParam, } from '@graphprotocol/grc-20'; import type { Entity } from '@graphprotocol/hypergraph'; -import { store, Type } from '@graphprotocol/hypergraph'; +import { store, TypeUtils } from '@graphprotocol/hypergraph'; import request, { gql } from 'graphql-request'; export type PreparePublishParams = { @@ -68,14 +68,20 @@ export const preparePublish = async ({ if (data?.entity === null) { for (const [key, propertyId] of Object.entries(mappingEntry.properties || {})) { + if (entity[key] === undefined) { + if (TypeUtils.isOptional(fields[key])) { + continue; + } + throw new Error(`Value for ${key} is undefined`); + } let serializedValue: string = entity[key]; - if (fields[key] === Type.Checkbox) { + if (TypeUtils.isCheckboxOrOptionalCheckboxType(fields[key])) { serializedValue = Graph.serializeCheckbox(entity[key]); - } else if (fields[key] === Type.Date) { + } else if (TypeUtils.isDateOrOptionalDateType(fields[key])) { serializedValue = Graph.serializeDate(entity[key]); - } else if (fields[key] === Type.Point) { + } else if (TypeUtils.isPointOrOptionalPointType(fields[key])) { serializedValue = Graph.serializePoint(entity[key]); - } else if (fields[key] === Type.Number) { + } else if (TypeUtils.isNumberOrOptionalNumberType(fields[key])) { serializedValue = Graph.serializeNumber(entity[key]); } values.push({ property: propertyId, value: serializedValue }); @@ -105,14 +111,20 @@ export const preparePublish = async ({ if (data?.entity) { for (const [key, propertyId] of Object.entries(mappingEntry.properties || {})) { + if (entity[key] === undefined) { + if (TypeUtils.isOptional(fields[key])) { + continue; + } + throw new Error(`Value for ${key} is undefined`); + } let serializedValue: string = entity[key]; - if (fields[key] === Type.Checkbox) { + if (TypeUtils.isCheckboxOrOptionalCheckboxType(fields[key])) { serializedValue = Graph.serializeCheckbox(entity[key]); - } else if (fields[key] === Type.Date) { + } else if (TypeUtils.isDateOrOptionalDateType(fields[key])) { serializedValue = Graph.serializeDate(entity[key]); - } else if (fields[key] === Type.Point) { + } else if (TypeUtils.isPointOrOptionalPointType(fields[key])) { serializedValue = Graph.serializePoint(entity[key]); - } else if (fields[key] === Type.Number) { + } else if (TypeUtils.isNumberOrOptionalNumberType(fields[key])) { serializedValue = Graph.serializeNumber(entity[key]); } diff --git a/packages/hypergraph/src/index.ts b/packages/hypergraph/src/index.ts index 1d95b205..722a4392 100644 --- a/packages/hypergraph/src/index.ts +++ b/packages/hypergraph/src/index.ts @@ -10,5 +10,6 @@ export * as SpaceInfo from './space-info/index.js'; export * from './store.js'; export * as StoreConnect from './store-connect.js'; export * as Type from './type/type.js'; +export * as TypeUtils from './type-utils/type-utils.js'; export * from './types.js'; export * as Utils from './utils/index.js'; diff --git a/packages/hypergraph/src/type-utils/type-utils.ts b/packages/hypergraph/src/type-utils/type-utils.ts new file mode 100644 index 00000000..325cddc5 --- /dev/null +++ b/packages/hypergraph/src/type-utils/type-utils.ts @@ -0,0 +1,46 @@ +import * as Type from '../type/type.js'; + +// biome-ignore lint/suspicious/noExplicitAny: TODO +export const isStringOrOptionalStringType = (type: any) => { + if (type.ast && type.ast._tag === 'PropertySignatureDeclaration' && type.ast.isOptional) { + return type === Type.Text; + } + return type === Type.Text; +}; + +// biome-ignore lint/suspicious/noExplicitAny: TODO +export const isNumberOrOptionalNumberType = (type: any) => { + if (type.ast && type.ast._tag === 'PropertySignatureDeclaration' && type.ast.isOptional) { + return type === Type.Number; + } + return type === Type.Number; +}; + +// biome-ignore lint/suspicious/noExplicitAny: TODO +export const isDateOrOptionalDateType = (type: any) => { + if (type.ast && type.ast._tag === 'PropertySignatureDeclaration' && type.ast.isOptional) { + return type === Type.Date; + } + return type === Type.Date; +}; + +// biome-ignore lint/suspicious/noExplicitAny: TODO +export const isCheckboxOrOptionalCheckboxType = (type: any) => { + if (type.ast && type.ast._tag === 'PropertySignatureDeclaration' && type.ast.isOptional) { + return type === Type.Checkbox; + } + return type === Type.Checkbox; +}; + +// biome-ignore lint/suspicious/noExplicitAny: TODO +export const isPointOrOptionalPointType = (type: any) => { + if (type.ast && type.ast._tag === 'PropertySignatureDeclaration' && type.ast.isOptional) { + return type === Type.Point; + } + return type === Type.Point; +}; + +// biome-ignore lint/suspicious/noExplicitAny: TODO +export const isOptional = (type: any) => { + return type.ast && type.ast._tag === 'PropertySignatureDeclaration' && type.ast.isOptional; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40bd7d2c..7f906b74 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -374,6 +374,9 @@ importers: '@radix-ui/react-icons': specifier: ^1.3.2 version: 1.3.2(react@19.1.0) + '@radix-ui/react-label': + specifier: ^2.1.7 + version: 2.1.7(@types/react-dom@19.1.3(@types/react@19.1.3))(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) '@radix-ui/react-slot': specifier: ^1.2.2 version: 1.2.2(@types/react@19.1.3)(react@19.1.0) @@ -767,7 +770,7 @@ importers: specifier: ^4.1.0 version: 4.1.0 effect: - specifier: ^3.17.1 + specifier: ^3.17.0 version: 3.17.1 graphql: specifier: ^16.11.0 @@ -4351,6 +4354,19 @@ packages: '@types/react': optional: true + '@radix-ui/react-label@2.1.7': + resolution: {integrity: sha512-YT1GqPSL8kJn20djelMX7/cTRp/Y9w5IZHvfxQTVHrOqa2yMl7i/UfMqKRU5V7mEyKTrUVgJXhNQPVCG8PBLoQ==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-navigation-menu@1.2.13': resolution: {integrity: sha512-WG8wWfDiJlSF5hELjwfjSGOXcBR/ZMhBFCGYe8vERpC39CQYZeq1PQ2kaYHdye3V95d06H89KGMsVCIE4LWo3g==} peerDependencies: @@ -18619,6 +18635,15 @@ snapshots: optionalDependencies: '@types/react': 19.1.8 + '@radix-ui/react-label@2.1.7(@types/react-dom@19.1.3(@types/react@19.1.3))(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-primitive': 2.1.3(@types/react-dom@19.1.3(@types/react@19.1.3))(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.3 + '@types/react-dom': 19.1.3(@types/react@19.1.3) + '@radix-ui/react-navigation-menu@1.2.13(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/primitive': 1.1.2 @@ -18688,6 +18713,15 @@ snapshots: '@types/react': 19.1.3 '@types/react-dom': 19.1.3(@types/react@19.1.3) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.3(@types/react@19.1.3))(@types/react@19.1.3)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': + dependencies: + '@radix-ui/react-slot': 1.2.3(@types/react@19.1.3)(react@19.1.0) + react: 19.1.0 + react-dom: 19.1.0(react@19.1.0) + optionalDependencies: + '@types/react': 19.1.3 + '@types/react-dom': 19.1.3(@types/react@19.1.3) + '@radix-ui/react-primitive@2.1.3(@types/react-dom@19.1.6(@types/react@19.1.8))(@types/react@19.1.8)(react-dom@19.1.0(react@19.1.0))(react@19.1.0)': dependencies: '@radix-ui/react-slot': 1.2.3(@types/react@19.1.8)(react@19.1.0) @@ -18721,6 +18755,13 @@ snapshots: optionalDependencies: '@types/react': 19.1.3 + '@radix-ui/react-slot@1.2.3(@types/react@19.1.3)(react@19.1.0)': + dependencies: + '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.3)(react@19.1.0) + react: 19.1.0 + optionalDependencies: + '@types/react': 19.1.3 + '@radix-ui/react-slot@1.2.3(@types/react@19.1.8)(react@19.1.0)': dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@19.1.8)(react@19.1.0) From cbb0f401584647865d7a7c39e47ba68fc78b225b Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Tue, 29 Jul 2025 07:01:55 +0200 Subject: [PATCH 04/11] increase limit --- apps/events/src/components/playground.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/events/src/components/playground.tsx b/apps/events/src/components/playground.tsx index d0cc8c2e..732ce722 100644 --- a/apps/events/src/components/playground.tsx +++ b/apps/events/src/components/playground.tsx @@ -17,7 +17,7 @@ export const Playground = ({ spaceId }: { spaceId: string }) => { jobOffers: {}, }, }, - first: 2, + first: 10, space: spaceId, }); const [isDeleting, setIsDeleting] = useState(false); From 76dc06ae2ea83119838b2579a855d0d800dcd1f6 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Tue, 29 Jul 2025 16:37:32 +0200 Subject: [PATCH 05/11] remove TODOs --- apps/typesync/client/src/Components/App/Schema/utils.ts | 1 - apps/typesync/src/Generator.ts | 5 ++--- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/apps/typesync/client/src/Components/App/Schema/utils.ts b/apps/typesync/client/src/Components/App/Schema/utils.ts index 8be83f18..52d85fa4 100644 --- a/apps/typesync/client/src/Components/App/Schema/utils.ts +++ b/apps/typesync/client/src/Components/App/Schema/utils.ts @@ -1,6 +1,5 @@ import type { AppSchema } from '../../../schema.js'; -// TODO function fieldToEntityString({ name, type_name, diff --git a/apps/typesync/src/Generator.ts b/apps/typesync/src/Generator.ts index c6b4e94d..3f0ad035 100644 --- a/apps/typesync/src/Generator.ts +++ b/apps/typesync/src/Generator.ts @@ -1,10 +1,10 @@ -import { execSync } from 'node:child_process'; -import { readdirSync } from 'node:fs'; import { FileSystem, Path, type Error as PlatformError } from '@effect/platform'; import { NodeFileSystem } from '@effect/platform-node'; import { Doc } from '@effect/printer'; import { Mapping } from '@graphprotocol/typesync'; import { Cause, Console, Data, Effect, Array as EffectArray, String as EffectString } from 'effect'; +import { execSync } from 'node:child_process'; +import { readdirSync } from 'node:fs'; import type * as Domain from '../domain/Domain.js'; import * as Utils from './Utils.js'; @@ -301,7 +301,6 @@ function validatePackageName(name: string): { }; } -// TODO // -------------------- // schema builder // -------------------- From 53d51664d2dbd31ab6dd0b5f90e9c9a52a68389f Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Tue, 29 Jul 2025 16:58:25 +0200 Subject: [PATCH 06/11] add tests --- .../test/prepare-publish.test.ts | 376 ++++++++++++++++++ 1 file changed, 376 insertions(+) create mode 100644 packages/hypergraph-react/test/prepare-publish.test.ts diff --git a/packages/hypergraph-react/test/prepare-publish.test.ts b/packages/hypergraph-react/test/prepare-publish.test.ts new file mode 100644 index 00000000..924677c3 --- /dev/null +++ b/packages/hypergraph-react/test/prepare-publish.test.ts @@ -0,0 +1,376 @@ +import { Repo } from '@automerge/automerge-repo'; +import { Graph, Id } from '@graphprotocol/grc-20'; +import { Entity, store, Type } from '@graphprotocol/hypergraph'; +import '@testing-library/jest-dom/vitest'; +import request from 'graphql-request'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { type PreparePublishParams, preparePublish } from '../src/prepare-publish.js'; + +// Mock graphql-request +vi.mock('graphql-request', () => ({ + default: vi.fn(), + gql: vi.fn((strings: TemplateStringsArray) => strings.join('')), +})); + +const mockRequest = vi.mocked(request); + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe('preparePublish', () => { + // Test entity classes + class Person extends Entity.Class('Person')({ + name: Type.Text, + age: Type.Number, + email: Type.optional(Type.Text), + isActive: Type.Checkbox, + birthDate: Type.Date, + location: Type.Point, + }) {} + + class Company extends Entity.Class('Company')({ + name: Type.Text, + employees: Type.Relation(Person), + }) {} + + const spaceId = '1e5e39da-a00d-4fd8-b53b-98095337112f'; + const publicSpaceId = '2e5e39da-a00d-4fd8-b53b-98095337112f'; + + let repo: Repo; + + beforeEach(() => { + repo = new Repo({}); + store.send({ type: 'setRepo', repo }); + + // Set up mapping in store + store.send({ + type: 'setMapping', + mapping: { + Person: { + typeIds: [Id.Id('a06dd0c6-3d38-4be1-a865-8c95be0ca35a')], + properties: { + name: Id.Id('ed49ed7b-17b3-4df6-b0b5-11f78d82e151'), + age: Id.Id('a427183d-3519-4c96-b80a-5a0c64daed41'), + email: Id.Id('43d6f432-c661-4c05-bc65-5ddacdfd50bf'), + isActive: Id.Id('e4259554-42b1-46e4-84c3-f8681987770f'), + birthDate: Id.Id('b5c0e2c7-9ac9-415e-8ffe-34f8b530f126'), + location: Id.Id('45e707a5-4364-42fb-bb0b-927a5a8bc061'), + }, + relations: {}, + }, + Company: { + typeIds: [Id.Id('1d113495-a1d8-4520-be14-8bc5378dc4ad')], + properties: { + name: Id.Id('907722dc-2cd1-4bae-a81b-263186b29dff'), + }, + relations: { + employees: Id.Id('6530b1dc-24ce-46ca-95e7-e89e87dd3839'), + }, + }, + }, + }); + + store.send({ + type: 'setSpace', + spaceId, + spaceState: { + id: spaceId, + members: {}, + invitations: {}, + removedMembers: {}, + inboxes: {}, + lastEventHash: '', + }, + name: 'Test Space', + updates: { updates: [], firstUpdateClock: 0, lastUpdateClock: 0 }, + events: [], + inboxes: [], + keys: [], + }); + }); + + describe('creating new entity (when entity does not exist in public space)', () => { + beforeEach(() => { + // Mock GraphQL response for non-existent entity + mockRequest.mockResolvedValue({ + entity: null, + }); + }); + + it('should create ops for a new entity with all required fields', async () => { + const entity = { + id: 'b7a8be83-7313-441b-804c-4798f1e9aca7', + type: 'Person', + name: 'John Doe', + age: 30, + email: 'john@example.com', + isActive: true, + birthDate: new Date('1993-01-01'), + location: [10.5, 20.3], + __schema: Person, + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + const result = await preparePublish(params); + + expect(mockRequest).toHaveBeenCalledWith(`${Graph.TESTNET_API_ORIGIN}/graphql`, expect.any(String), { + entityId: entity.id, + spaceId: publicSpaceId, + }); + + expect(result.ops).toBeDefined(); + expect(result.ops.length).toBeGreaterThan(0); + + // Since we can't easily mock Graph.createEntity, we'll check the structure + expect(result.ops).toBeInstanceOf(Array); + }); + + it('should handle optional fields correctly when undefined', async () => { + const entity = { + id: '224e5e89-a4d0-49de-ae1b-a94533e7e464', + type: 'Person', + name: 'Jane Doe', + age: 25, + isActive: false, + birthDate: new Date('1998-01-01'), + location: [0, 0], + __schema: Person, + // email is optional and undefined + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + const result = await preparePublish(params); + + expect(result.ops).toBeDefined(); + expect(result.ops.length).toBeGreaterThan(0); + }); + + it('should throw error when required field is undefined', async () => { + const entity = { + id: '7f8c9d2e-4b5a-6c7d-8e9f-0a1b2c3d4e5f', + type: 'Person', + age: 25, + isActive: false, + birthDate: new Date('1998-01-01'), + location: [0, 0], + __schema: Person, + // name is required but undefined + } as any; + + // Manually set name to undefined to test error case + entity.name = undefined; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + await expect(preparePublish(params)).rejects.toThrow('Value for name is undefined'); + }); + + it.skip('should handle entities with relations', async () => { + const employee1 = { + id: 'f219bb22-5c2e-4923-8f1d-4565f362673d', + type: 'Person', + name: 'Employee 1', + age: 30, + isActive: true, + birthDate: new Date('1993-01-01'), + location: [0, 0], + __schema: Person, + } as Entity.Entity; + + const employee2 = { + id: '94f01f8f-eb19-4fed-9c03-8e875058dc2a', + type: 'Person', + name: 'Employee 2', + age: 25, + isActive: true, + birthDate: new Date('1998-01-01'), + location: [0, 0], + __schema: Person, + } as Entity.Entity; + + const company = { + id: 'd2033169-590f-4b88-bf18-4719949ea953', + type: 'Company', + name: 'Test Company', + employees: [{ ...employee1 }, { ...employee2 }], + __schema: Company, + } as any; + + const params: PreparePublishParams = { + entity: company, + publicSpace: publicSpaceId, + }; + + const result = await preparePublish(params); + + expect(result.ops).toBeDefined(); + expect(result.ops.length).toBeGreaterThan(0); + }); + }); + + describe('updating existing entity', () => { + beforeEach(() => { + // Mock GraphQL response for existing entity + mockRequest.mockResolvedValue({ + entity: { + valuesList: [ + { propertyId: 'ed49ed7b-17b3-4df6-b0b5-11f78d82e151', value: 'Old Name' }, + { propertyId: 'a427183d-3519-4c96-b80a-5a0c64daed41', value: Graph.serializeNumber(25) }, + { propertyId: 'e4259554-42b1-46e4-84c3-f8681987770f', value: Graph.serializeCheckbox(false) }, + ], + relationsList: [], + }, + }); + }); + + it('should create update ops only for changed values', async () => { + const entity = { + id: '19085414-a281-4472-a70d-aec835074be4', + type: 'Person', + name: 'New Name', // Changed from 'Old Name' + age: 25, // Same as existing + email: 'new@example.com', // New optional field + isActive: true, // Changed from false + birthDate: new Date('1998-01-01'), + location: [0, 0], + __schema: Person, + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + const result = await preparePublish(params); + + expect(result.ops).toBeDefined(); + // Should only include ops for changed/new values + }); + + it('should not create ops when no values have changed', async () => { + // Mock response with all current values + mockRequest.mockResolvedValue({ + entity: { + valuesList: [ + { propertyId: 'ed49ed7b-17b3-4df6-b0b5-11f78d82e151', value: 'Same Name' }, + { propertyId: 'a427183d-3519-4c96-b80a-5a0c64daed41', value: Graph.serializeNumber(30) }, + { propertyId: 'e4259554-42b1-46e4-84c3-f8681987770f', value: Graph.serializeCheckbox(true) }, + { propertyId: 'b5c0e2c7-9ac9-415e-8ffe-34f8b530f126', value: Graph.serializeDate(new Date('1993-01-01')) }, + { propertyId: '45e707a5-4364-42fb-bb0b-927a5a8bc061', value: Graph.serializePoint([0, 0]) }, + ], + relationsList: [], + }, + }); + + const entity = { + id: '8f9a0b1c-2d3e-4f5a-6b7c-8d9e0f1a2b3c', + type: 'Person', + name: 'Same Name', + age: 30, + isActive: true, + birthDate: new Date('1993-01-01'), + location: [0, 0], + __schema: Person, + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + const result = await preparePublish(params); + + expect(result.ops).toHaveLength(0); + }); + }); + + describe('error cases', () => { + beforeEach(() => { + mockRequest.mockResolvedValue({ entity: null }); + }); + + it('should throw error when mapping entry is not found', async () => { + class UnmappedEntity extends Entity.Class('UnmappedEntity')({ + name: Type.Text, + }) {} + + const entity = { + id: '3c4d5e6f-7a8b-9c0d-1e2f-3a4b5c6d7e8f', + type: 'UnmappedEntity', + name: 'Test', + __schema: UnmappedEntity, + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + await expect(preparePublish(params)).rejects.toThrow('Mapping entry for UnmappedEntity not found'); + }); + + it('should handle GraphQL request failures', async () => { + mockRequest.mockRejectedValue(new Error('Network error')); + + const entity = { + id: '5e6f7a8b-9c0d-1e2f-3a4b-5c6d7e8f9a0b', + type: 'Person', + name: 'Test Person', + age: 30, + isActive: true, + birthDate: new Date('1993-01-01'), + location: [0, 0], + __schema: Person, + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + await expect(preparePublish(params)).rejects.toThrow('Network error'); + }); + }); + + describe('field type serialization', () => { + beforeEach(() => { + mockRequest.mockResolvedValue({ entity: null }); + }); + + it('should handle different field types without errors', async () => { + const entity = { + id: '1a2b3c4d-5e6f-7a8b-9c0d-1e2f3a4b5c6d', + type: 'Person', + name: 'Test Person', + age: 42, + isActive: true, + birthDate: new Date('1980-05-15'), + location: [123.456, 789.012], + __schema: Person, + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + // Just ensure the function executes without error for different field types + const result = await preparePublish(params); + + expect(result.ops).toBeDefined(); + expect(result.ops.length).toBeGreaterThan(0); + }); + }); +}); From b254095fef939fc07d9faeaaa4ec2176005b48e8 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Tue, 29 Jul 2025 17:01:22 +0200 Subject: [PATCH 07/11] add documentation --- docs/docs/schema.md | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/docs/schema.md b/docs/docs/schema.md index 412965e7..120e0c00 100644 --- a/docs/docs/schema.md +++ b/docs/docs/schema.md @@ -2,8 +2,6 @@ The Hypergraph schema allows you to define the data model for your application. It is based on the GRC-20 specification and allows you to define Types with properties and relations to other Types. -TODO update this doc - ## Example Here is an example of a schema for an Event app with the properties `name` and `description`. @@ -58,6 +56,20 @@ export class Company extends Entity.Class('Company')({ }) {} ``` +## Optional Fields + +You can make a field optional by wrapping it in `Type.optional`. + +```ts +import { Entity, Type } from '@graphprotocol/hypergraph'; + +export class Company extends Entity.Class('Company')({ + name: Type.Text, + description: Type.optional(Type.Text), + founded: Type.optional(Type.Date), +}) {} +``` + ## Schema Examples You can search for dozens of schema/mapping examples on the [Hypergraph Schema Browser](https://schema-browser.vercel.app/). \ No newline at end of file From 9e8806c2ebbb01b839b734be3c856b28f8e9a904 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Tue, 29 Jul 2025 22:37:21 +0200 Subject: [PATCH 08/11] improve test and fix lint --- apps/typesync/src/Generator.ts | 4 ++-- packages/hypergraph-react/test/prepare-publish.test.ts | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/typesync/src/Generator.ts b/apps/typesync/src/Generator.ts index 3f0ad035..ecb2bd36 100644 --- a/apps/typesync/src/Generator.ts +++ b/apps/typesync/src/Generator.ts @@ -1,10 +1,10 @@ +import { execSync } from 'node:child_process'; +import { readdirSync } from 'node:fs'; import { FileSystem, Path, type Error as PlatformError } from '@effect/platform'; import { NodeFileSystem } from '@effect/platform-node'; import { Doc } from '@effect/printer'; import { Mapping } from '@graphprotocol/typesync'; import { Cause, Console, Data, Effect, Array as EffectArray, String as EffectString } from 'effect'; -import { execSync } from 'node:child_process'; -import { readdirSync } from 'node:fs'; import type * as Domain from '../domain/Domain.js'; import * as Utils from './Utils.js'; diff --git a/packages/hypergraph-react/test/prepare-publish.test.ts b/packages/hypergraph-react/test/prepare-publish.test.ts index 924677c3..9726da3f 100644 --- a/packages/hypergraph-react/test/prepare-publish.test.ts +++ b/packages/hypergraph-react/test/prepare-publish.test.ts @@ -155,7 +155,7 @@ describe('preparePublish', () => { }); it('should throw error when required field is undefined', async () => { - const entity = { + const entity: Entity.Entity = { id: '7f8c9d2e-4b5a-6c7d-8e9f-0a1b2c3d4e5f', type: 'Person', age: 25, @@ -164,7 +164,7 @@ describe('preparePublish', () => { location: [0, 0], __schema: Person, // name is required but undefined - } as any; + }; // Manually set name to undefined to test error case entity.name = undefined; @@ -200,13 +200,13 @@ describe('preparePublish', () => { __schema: Person, } as Entity.Entity; - const company = { + const company: Entity.Entity = { id: 'd2033169-590f-4b88-bf18-4719949ea953', type: 'Company', name: 'Test Company', employees: [{ ...employee1 }, { ...employee2 }], __schema: Company, - } as any; + }; const params: PreparePublishParams = { entity: company, From 7749422150340e44d0b22b674182e19ff597ee1b Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 30 Jul 2025 18:54:12 +0200 Subject: [PATCH 09/11] fix listing spaces without a name --- packages/hypergraph-react/src/hooks/use-spaces.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/hypergraph-react/src/hooks/use-spaces.ts b/packages/hypergraph-react/src/hooks/use-spaces.ts index 80f83cb2..21eccad6 100644 --- a/packages/hypergraph-react/src/hooks/use-spaces.ts +++ b/packages/hypergraph-react/src/hooks/use-spaces.ts @@ -24,7 +24,7 @@ type PublicSpacesQueryResult = { spaceAddress: string; page: { name: string; - }; + } | null; }[]; }; @@ -43,7 +43,7 @@ export const useSpaces = (params: { mode: 'public' | 'private' }) => { return result?.spaces ? result.spaces.map((space) => ({ id: space.id, - name: space.page.name, + name: space.page?.name, spaceAddress: space.spaceAddress, })) : []; From 77045db69e9ebd0c0f5c1f8c6efdcd780fe00112 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 30 Jul 2025 19:20:27 +0200 Subject: [PATCH 10/11] fix optional type check and add tests --- .../test/prepare-publish.test.ts | 330 +++++++++++++++++- .../hypergraph/src/type-utils/type-utils.ts | 10 +- 2 files changed, 327 insertions(+), 13 deletions(-) diff --git a/packages/hypergraph-react/test/prepare-publish.test.ts b/packages/hypergraph-react/test/prepare-publish.test.ts index 9726da3f..6a378cbd 100644 --- a/packages/hypergraph-react/test/prepare-publish.test.ts +++ b/packages/hypergraph-react/test/prepare-publish.test.ts @@ -34,6 +34,15 @@ describe('preparePublish', () => { employees: Type.Relation(Person), }) {} + // Entity class for testing optional types + class OptionalFieldsEntity extends Entity.Class('OptionalFieldsEntity')({ + name: Type.Text, // required field + optionalNumber: Type.optional(Type.Number), + optionalCheckbox: Type.optional(Type.Checkbox), + optionalDate: Type.optional(Type.Date), + optionalPoint: Type.optional(Type.Point), + }) {} + const spaceId = '1e5e39da-a00d-4fd8-b53b-98095337112f'; const publicSpaceId = '2e5e39da-a00d-4fd8-b53b-98095337112f'; @@ -68,6 +77,17 @@ describe('preparePublish', () => { employees: Id.Id('6530b1dc-24ce-46ca-95e7-e89e87dd3839'), }, }, + OptionalFieldsEntity: { + typeIds: [Id.Id('3f9e28c1-5b7d-4e8f-9a2c-6d5e4f3a2b1c')], + properties: { + name: Id.Id('2a8b9c7d-4e5f-6a7b-8c9d-0e1f2a3b4c5d'), + optionalNumber: Id.Id('eaf9f4f8-5647-4228-aff5-8725368fc87c'), + optionalCheckbox: Id.Id('2742d8b6-3059-4adb-b439-fdfcd588dccb'), + optionalDate: Id.Id('9b53690f-ea6d-4bd8-b4d3-9ea01e7f837f'), + optionalPoint: Id.Id('0c1d2e3f-4a5b-4c7d-8e9f-0a1b2c3d4e5f'), + }, + relations: {}, + }, }, }); @@ -155,7 +175,7 @@ describe('preparePublish', () => { }); it('should throw error when required field is undefined', async () => { - const entity: Entity.Entity = { + const entity = { id: '7f8c9d2e-4b5a-6c7d-8e9f-0a1b2c3d4e5f', type: 'Person', age: 25, @@ -164,10 +184,7 @@ describe('preparePublish', () => { location: [0, 0], __schema: Person, // name is required but undefined - }; - - // Manually set name to undefined to test error case - entity.name = undefined; + } as unknown as Entity.Entity; const params: PreparePublishParams = { entity, @@ -200,13 +217,16 @@ describe('preparePublish', () => { __schema: Person, } as Entity.Entity; - const company: Entity.Entity = { + const company = { id: 'd2033169-590f-4b88-bf18-4719949ea953', type: 'Company', name: 'Test Company', - employees: [{ ...employee1 }, { ...employee2 }], + employees: [ + { ...employee1, _relation: { id: 'ba8a247b-af9d-40eb-ad75-aa8a23fb9911' } }, + { ...employee2, _relation: { id: '4f7504e8-f2cc-4284-b2f2-2cd7fe1a6d90' } }, + ], __schema: Company, - }; + } as unknown as Entity.Entity; const params: PreparePublishParams = { entity: company, @@ -373,4 +393,298 @@ describe('preparePublish', () => { expect(result.ops.length).toBeGreaterThan(0); }); }); + + describe('optional field types', () => { + beforeEach(() => { + mockRequest.mockResolvedValue({ entity: null }); + }); + + it('should create entity with all optional fields present', async () => { + const entity = { + id: 'a1b2c3d4-e5f6-7a8b-9c0d-1e2f3a4b5c6d', + type: 'OptionalFieldsEntity', + name: 'Test Entity', + optionalNumber: 42.5, + optionalCheckbox: true, + optionalDate: new Date('2024-01-15'), + optionalPoint: [12.34, 56.78], + __schema: OptionalFieldsEntity, + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + const result = await preparePublish(params); + + expect(result.ops).toBeDefined(); + expect(result.ops.length).toBeGreaterThan(0); + }); + + it('should create entity with some optional fields undefined', async () => { + const entity = { + id: '4d77f7bc-fb12-4a8e-9224-99b0b5cb09a9', + type: 'OptionalFieldsEntity', + name: 'Test Entity', + optionalNumber: 25, + // optionalCheckbox is undefined + optionalDate: new Date('2024-02-20'), + // optionalPoint is undefined + __schema: OptionalFieldsEntity, + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + const result = await preparePublish(params); + + expect(result.ops).toBeDefined(); + expect(result.ops.length).toBeGreaterThan(0); + }); + + it('should create entity with only required fields (all optional fields undefined)', async () => { + const entity = { + id: 'c3d4e5f6-a7b8-4c9d-8e2f-3a4b5c6d7e8f', + type: 'OptionalFieldsEntity', + name: 'Minimal Entity', + // All optional fields are undefined + __schema: OptionalFieldsEntity, + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + const result = await preparePublish(params); + + expect(result.ops).toBeDefined(); + expect(result.ops.length).toBeGreaterThan(0); + }); + + it('should handle optional Number field variations', async () => { + const testCases = [ + { value: 0, description: 'zero' }, + { value: -15.5, description: 'negative decimal' }, + { value: 999999, description: 'large integer' }, + { value: undefined, description: 'undefined' }, + ]; + + for (const testCase of testCases) { + const entity = { + id: '6ced2b76-e465-47de-a7ff-ac9b27a41fd4', + type: 'OptionalFieldsEntity', + name: `Test ${testCase.description}`, + optionalNumber: testCase.value, + __schema: OptionalFieldsEntity, + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + const result = await preparePublish(params); + expect(result.ops).toBeDefined(); + } + }); + + it('should handle optional Checkbox field variations', async () => { + const testCases = [ + { value: true, description: 'true' }, + { value: false, description: 'false' }, + { value: undefined, description: 'undefined' }, + ]; + + for (const testCase of testCases) { + const entity = { + id: 'e68aa940-8452-48de-8523-292ba3771f81', + type: 'OptionalFieldsEntity', + name: `Test ${testCase.description}`, + optionalCheckbox: testCase.value, + __schema: OptionalFieldsEntity, + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + const result = await preparePublish(params); + expect(result.ops).toBeDefined(); + } + }); + + it('should handle optional Date field variations', async () => { + const testCases = [ + { value: new Date('2024-01-01'), description: 'valid date' }, + { value: new Date('1900-01-01'), description: 'old date' }, + { value: new Date('2100-12-31'), description: 'future date' }, + { value: undefined, description: 'undefined' }, + ]; + + for (const testCase of testCases) { + const entity = { + id: 'fde9afb6-8c58-45bd-86a7-1e5222f92284', + type: 'OptionalFieldsEntity', + name: `Test ${testCase.description}`, + optionalDate: testCase.value, + __schema: OptionalFieldsEntity, + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + const result = await preparePublish(params); + expect(result.ops).toBeDefined(); + } + }); + + it('should handle optional Point field variations', async () => { + const testCases = [ + { value: [0, 0], description: 'origin' }, + { value: [-90, -180], description: 'negative coordinates' }, + { value: [90.123456, 180.654321], description: 'precise coordinates' }, + { value: undefined, description: 'undefined' }, + ]; + + for (const testCase of testCases) { + const entity = { + id: '539cb728-ca6e-4d3c-ae6f-0b5b6bcb570a', + type: 'OptionalFieldsEntity', + name: `Test ${testCase.description}`, + optionalPoint: testCase.value, + __schema: OptionalFieldsEntity, + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + const result = await preparePublish(params); + expect(result.ops).toBeDefined(); + } + }); + + it('should throw error when required field is missing from optional fields entity', async () => { + const entity = { + id: 'd4e5f6a7-b8c9-4d1e-af3a-4b5c6d7e8f9a', + type: 'OptionalFieldsEntity', + // name is missing (required field) + optionalNumber: 42, + __schema: OptionalFieldsEntity, + } as unknown as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + await expect(preparePublish(params)).rejects.toThrow('Value for name is undefined'); + }); + }); + + describe('updating entities with optional fields', () => { + it('should add optional fields to existing entity', async () => { + // Mock existing entity with only required field + mockRequest.mockResolvedValue({ + entity: { + valuesList: [{ propertyId: '2a8b9c7d-4e5f-6a7b-8c9d-0e1f2a3b4c5d', value: 'Existing Entity' }], + relationsList: [], + }, + }); + + const entity = { + id: 'e5f6a7b8-c9d0-4e2f-ba4b-5c6d7e8f9a0b', + type: 'OptionalFieldsEntity', + name: 'Existing Entity', + optionalNumber: 100, // New field + optionalCheckbox: true, // New field + optionalDate: new Date('2024-03-15'), // New field + optionalPoint: [45.0, 90.0], // New field + __schema: OptionalFieldsEntity, + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + const result = await preparePublish(params); + + expect(result.ops).toBeDefined(); + expect(result.ops.length).toBeGreaterThan(0); + }); + + it('should remove optional fields from existing entity (set to undefined)', async () => { + // Mock existing entity with optional fields + mockRequest.mockResolvedValue({ + entity: { + valuesList: [ + { propertyId: '2a8b9c7d-4e5f-6a7b-8c9d-0e1f2a3b4c5d', value: 'Existing Entity' }, + { propertyId: 'eaf9f4f8-5647-4228-aff5-8725368fc87c', value: Graph.serializeNumber(50) }, + { propertyId: '2742d8b6-3059-4adb-b439-fdfcd588dccb', value: Graph.serializeCheckbox(true) }, + ], + relationsList: [], + }, + }); + + const entity = { + id: 'f6a7b8c9-d0e1-4f3a-bb5c-6d7e8f9a0b1c', + type: 'OptionalFieldsEntity', + name: 'Existing Entity', + // All optional fields are now undefined (removed) + __schema: OptionalFieldsEntity, + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + const result = await preparePublish(params); + + expect(result.ops).toBeDefined(); + }); + + it('should update some optional fields while keeping others', async () => { + // Mock existing entity with mixed optional fields + mockRequest.mockResolvedValue({ + entity: { + valuesList: [ + { propertyId: '2a8b9c7d-4e5f-6a7b-8c9d-0e1f2a3b4c5d', value: 'Existing Entity' }, + { propertyId: 'eaf9f4f8-5647-4228-aff5-8725368fc87c', value: Graph.serializeNumber(75) }, + { propertyId: '9b53690f-ea6d-4bd8-b4d3-9ea01e7f837f', value: Graph.serializeDate(new Date('2023-01-01')) }, + ], + relationsList: [], + }, + }); + + const entity = { + id: '809c9f0a-dbe5-4208-9092-17135f282613', + type: 'OptionalFieldsEntity', + name: 'Existing Entity', + optionalNumber: 125, // Changed from 75 + // optionalCheckbox: undefined (not present, will remain undefined) + optionalDate: new Date('2023-01-01'), // Same as existing (no change) + optionalPoint: [12.5, 25.0], // New field + __schema: OptionalFieldsEntity, + } as Entity.Entity; + + const params: PreparePublishParams = { + entity, + publicSpace: publicSpaceId, + }; + + const result = await preparePublish(params); + + expect(result.ops).toBeDefined(); + }); + }); }); diff --git a/packages/hypergraph/src/type-utils/type-utils.ts b/packages/hypergraph/src/type-utils/type-utils.ts index 325cddc5..fdb1a6b4 100644 --- a/packages/hypergraph/src/type-utils/type-utils.ts +++ b/packages/hypergraph/src/type-utils/type-utils.ts @@ -3,7 +3,7 @@ import * as Type from '../type/type.js'; // biome-ignore lint/suspicious/noExplicitAny: TODO export const isStringOrOptionalStringType = (type: any) => { if (type.ast && type.ast._tag === 'PropertySignatureDeclaration' && type.ast.isOptional) { - return type === Type.Text; + return type.from === Type.Text; } return type === Type.Text; }; @@ -11,7 +11,7 @@ export const isStringOrOptionalStringType = (type: any) => { // biome-ignore lint/suspicious/noExplicitAny: TODO export const isNumberOrOptionalNumberType = (type: any) => { if (type.ast && type.ast._tag === 'PropertySignatureDeclaration' && type.ast.isOptional) { - return type === Type.Number; + return type.from === Type.Number; } return type === Type.Number; }; @@ -19,7 +19,7 @@ export const isNumberOrOptionalNumberType = (type: any) => { // biome-ignore lint/suspicious/noExplicitAny: TODO export const isDateOrOptionalDateType = (type: any) => { if (type.ast && type.ast._tag === 'PropertySignatureDeclaration' && type.ast.isOptional) { - return type === Type.Date; + return type.from === Type.Date; } return type === Type.Date; }; @@ -27,7 +27,7 @@ export const isDateOrOptionalDateType = (type: any) => { // biome-ignore lint/suspicious/noExplicitAny: TODO export const isCheckboxOrOptionalCheckboxType = (type: any) => { if (type.ast && type.ast._tag === 'PropertySignatureDeclaration' && type.ast.isOptional) { - return type === Type.Checkbox; + return type.from === Type.Checkbox; } return type === Type.Checkbox; }; @@ -35,7 +35,7 @@ export const isCheckboxOrOptionalCheckboxType = (type: any) => { // biome-ignore lint/suspicious/noExplicitAny: TODO export const isPointOrOptionalPointType = (type: any) => { if (type.ast && type.ast._tag === 'PropertySignatureDeclaration' && type.ast.isOptional) { - return type === Type.Point; + return type.from === Type.Point; } return type === Type.Point; }; From bbf8e36d7a221a0d1022b933e68dbebcdaaf9ce5 Mon Sep 17 00:00:00 2001 From: Nik Graf Date: Wed, 30 Jul 2025 19:51:25 +0200 Subject: [PATCH 11/11] add changeset --- .changeset/pretty-showers-tap.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 .changeset/pretty-showers-tap.md diff --git a/.changeset/pretty-showers-tap.md b/.changeset/pretty-showers-tap.md new file mode 100644 index 00000000..240f3787 --- /dev/null +++ b/.changeset/pretty-showers-tap.md @@ -0,0 +1,7 @@ +--- +"@graphprotocol/hypergraph-react": patch +"@graphprotocol/hypergraph": patch +--- + +add Type.optional + \ No newline at end of file