import * as Option from 'effect/Option'; import * as Schema from 'effect/Schema'; import * as SchemaAST from 'effect/SchemaAST'; import { PropertyIdSymbol, PropertyTypeSymbol, RelationBacklinkSymbol, RelationPropertiesSymbol, RelationSchemaSymbol, RelationSymbol, TypeIdsSymbol, } from '../constants.js'; type SchemaBuilderReturn = | Schema.Schema.AnyNoContext // biome-ignore lint/suspicious/noExplicitAny: effect schema property signature is intentionally untyped here | Schema.PropertySignature; // biome-ignore lint/suspicious/noExplicitAny: property builders accept property ids of varying shapes type SchemaBuilder = (propertyId: any) => SchemaBuilderReturn; type RelationPropertiesDefinition = Record; type RelationOptionsBase = { backlink?: boolean; }; type RelationOptions = RelationOptionsBase & { properties: RP; }; export const relationSchemaBrand = '__hypergraphRelationSchema' as const; type RelationSchemaMarker = { readonly [relationSchemaBrand]: true; }; export const relationBuilderBrand = '__hypergraphRelationBuilder' as const; type RelationBuilderMarker = { readonly [relationBuilderBrand]: true; }; const hasRelationProperties = ( options: RelationOptionsBase | RelationOptions | undefined, ): options is RelationOptions => { if (!options) return false; return 'properties' in options; }; type RelationMappingInput = RP extends RelationPropertiesDefinition ? { propertyId: string; properties: { [K in keyof RP]: Parameters[0]; }; } : string | { propertyId: string }; type RelationPropertyValue = RP extends RelationPropertiesDefinition ? { readonly [K in keyof RP]: Schema.Schema.Type>; } : Record; type RelationPropertyEncoded = RP extends RelationPropertiesDefinition ? { readonly [K in keyof RP]: Schema.Schema.Encoded>; } : Record; type RelationMetadata = { readonly id: string; } & RelationPropertyValue; type RelationMetadataEncoded = { readonly id: string; } & RelationPropertyEncoded; type RelationSchema< S extends Schema.Schema.AnyNoContext, RP extends RelationPropertiesDefinition | undefined = undefined, > = Schema.Schema< readonly (Schema.Schema.Type & { readonly id: string; readonly _relation: RelationMetadata })[], readonly (Schema.Schema.Encoded & { readonly id: string; readonly _relation: RelationMetadataEncoded })[], never > & RelationSchemaMarker; type RelationSchemaBuilder< S extends Schema.Schema.AnyNoContext, RP extends RelationPropertiesDefinition | undefined = undefined, > = ((mapping: RelationMappingInput) => RelationSchema) & RelationBuilderMarker; export type AnyRelationSchema = // biome-ignore lint/suspicious/noExplicitAny: relation schema branding requires broad typing | (Schema.Schema & RelationSchemaMarker) // biome-ignore lint/suspicious/noExplicitAny: relation schema branding requires broad typing | (Schema.PropertySignature & RelationSchemaMarker); export type AnyRelationBuilder = RelationSchemaBuilder< Schema.Schema.AnyNoContext, RelationPropertiesDefinition | undefined >; /** * Creates a String schema with the specified GRC-20 property ID */ // biome-ignore lint/suspicious/noShadowRestrictedNames: is part of a namespaces module and therefor ok export const String = (propertyId: string) => { return Schema.String.pipe(Schema.annotations({ [PropertyIdSymbol]: propertyId, [PropertyTypeSymbol]: 'string' })); }; /** * Creates a Number schema with the specified GRC-20 property ID */ // biome-ignore lint/suspicious/noShadowRestrictedNames: is part of a namespaces module and therefor ok export const Number = (propertyId: string) => { return Schema.Number.pipe(Schema.annotations({ [PropertyIdSymbol]: propertyId, [PropertyTypeSymbol]: 'number' })); }; /** * Creates a Boolean schema with the specified GRC-20 property ID */ // biome-ignore lint/suspicious/noShadowRestrictedNames: is part of a namespaces module and therefor ok export const Boolean = (propertyId: string) => { return Schema.Boolean.pipe(Schema.annotations({ [PropertyIdSymbol]: propertyId, [PropertyTypeSymbol]: 'boolean' })); }; /** * Creates a Date schema with the specified GRC-20 property ID */ // biome-ignore lint/suspicious/noShadowRestrictedNames: is part of a namespaces module and therefor ok export const Date = (propertyId: string) => { return Schema.Date.pipe(Schema.annotations({ [PropertyIdSymbol]: propertyId, [PropertyTypeSymbol]: 'date' })); }; /** * Creates a ScheduleString schema with the specified GRC-20 property ID */ export const ScheduleString = (propertyId: string) => { return Schema.String.pipe(Schema.annotations({ [PropertyIdSymbol]: propertyId, [PropertyTypeSymbol]: 'schedule' })); }; export const Point = (propertyId: string) => Schema.transform(Schema.String, Schema.Array(Schema.Number), { strict: true, decode: (str: string) => { return str.split(',').map((n: string) => globalThis.Number(n)); }, encode: (points: readonly number[]) => points.join(','), }).pipe(Schema.annotations({ [PropertyIdSymbol]: propertyId, [PropertyTypeSymbol]: 'point' })); export function Relation( schema: S, options?: RelationOptionsBase, ): RelationSchemaBuilder; export function Relation( schema: S, options: RelationOptions, ): RelationSchemaBuilder; export function Relation< S extends Schema.Schema.AnyNoContext, RP extends RelationPropertiesDefinition | undefined = undefined, // biome-ignore lint/suspicious/noExplicitAny: implementation signature for overloads must use any >(schema: S, options?: RP extends RelationPropertiesDefinition ? RelationOptions : RelationOptionsBase): any { return (mapping: RelationMappingInput) => { const { propertyId, relationPropertyIds } = typeof mapping === 'string' ? { propertyId: mapping, relationPropertyIds: undefined as undefined } : typeof mapping === 'object' && mapping !== null && 'properties' in mapping ? { propertyId: mapping.propertyId, relationPropertyIds: mapping.properties } : { propertyId: mapping.propertyId, relationPropertyIds: undefined as undefined }; const typeIds = SchemaAST.getAnnotation(TypeIdsSymbol)(schema.ast as SchemaAST.TypeLiteral).pipe( Option.getOrElse(() => []), ); const relationEntityPropertiesSchemas: Record = {}; const normalizedOptions = options as | RelationOptionsBase | RelationOptions | undefined; const relationProperties = hasRelationProperties(normalizedOptions) ? normalizedOptions.properties : undefined; if (relationProperties) { for (const [key, schemaType] of Object.entries(relationProperties)) { const propertyMapping = relationPropertyIds?.[key]; relationEntityPropertiesSchemas[key] = schemaType(propertyMapping); } } const relationEntitySchemaStruct = Schema.Struct({ ...relationEntityPropertiesSchemas, }); const schemaWithId = Schema.asSchema( Schema.extend(schema)( Schema.Struct({ id: Schema.String, _relation: Schema.extend(relationEntitySchemaStruct)( Schema.Struct({ id: Schema.String, }), ), }), ), // manually adding the type ids to the schema since they get lost when extending the schema ).pipe( Schema.annotations({ [TypeIdsSymbol]: typeIds, [RelationPropertiesSymbol]: relationEntitySchemaStruct, }), ) as Schema.Schema< Schema.Schema.Type & { readonly id: string; readonly _relation: RelationMetadata }, Schema.Schema.Encoded & { readonly id: string; readonly _relation: RelationMetadataEncoded }, never >; const isBacklinkRelation = !!normalizedOptions?.backlink; const relationSchema = Schema.Array(schemaWithId).pipe( Schema.annotations({ [PropertyIdSymbol]: propertyId, [RelationSchemaSymbol]: schema, [RelationSymbol]: true, [PropertyTypeSymbol]: 'relation', [RelationBacklinkSymbol]: isBacklinkRelation, }), ); Object.defineProperty(relationSchema, relationSchemaBrand, { value: true, enumerable: false, configurable: false, }); return relationSchema as unknown as RelationSchema; }; } export function Backlink< S extends Schema.Schema.AnyNoContext, RP extends RelationPropertiesDefinition | undefined = undefined, >(schema: S, options?: RP extends RelationPropertiesDefinition ? RelationOptions : RelationOptionsBase) { const normalizedOptions = { ...(options ?? {}), backlink: true, } as RP extends RelationPropertiesDefinition ? RelationOptions : RelationOptionsBase; return Relation(schema, normalizedOptions); } export const optional = (schemaFn: (propertyId: string) => S) => (propertyId: string) => { const innerSchema = schemaFn(propertyId); const optionalSchema = Schema.optional(innerSchema); if (relationSchemaBrand in (innerSchema as object)) { Object.defineProperty(optionalSchema, relationSchemaBrand, { value: true, enumerable: false, configurable: false, }); } return optionalSchema as typeof optionalSchema & RelationSchemaMarker; };