722 lines
27 KiB
JavaScript
722 lines
27 KiB
JavaScript
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import protobuf from "protobufjs";
|
|
const CLASS_OPTION = "(camino.class)";
|
|
const INTERFACE_OPTION = "(camino.interface)";
|
|
const IMPLEMENTS_OPTION = "(camino.implements)";
|
|
const CONSTRUCTOR_OPTION = "(camino.constructor)";
|
|
const METHOD_OPTION = "(camino.method)";
|
|
const MIGRATION_OPTION = "(camino.migration)";
|
|
const CONFLICT_OPTION = "(camino.conflict)";
|
|
const EDGE_OPTION = "(camino.edge)";
|
|
const FIELD_STORAGE_OPTION = "(camino.field_storage)";
|
|
const FIELD_OPS_OPTION = "(camino.field_ops)";
|
|
const INTERFACE_FIELD_OPTION = "(camino.interface_field)";
|
|
const DISPLAY_LABEL_OPTION = "(camino.display_label)";
|
|
const IMPL_OPTION = "(camino.impl)";
|
|
const SCHEMA_NAMESPACE_OPTION = "(camino.schema_namespace)";
|
|
const SCHEMA_VERSION_OPTION = "(camino.schema_version)";
|
|
const enumName = (value) => String(value)
|
|
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
|
|
.replace(/-/g, "_")
|
|
.toUpperCase();
|
|
const lowerEnumName = (value) => enumName(value).toLowerCase();
|
|
const getOption = (options, name) => {
|
|
if (!options) {
|
|
return undefined;
|
|
}
|
|
if (Object.hasOwn(options, name)) {
|
|
return options[name];
|
|
}
|
|
const bareName = name.replace(/^\((.*)\)$/, "$1");
|
|
if (Object.hasOwn(options, bareName)) {
|
|
return options[bareName];
|
|
}
|
|
return undefined;
|
|
};
|
|
const getParsedOptions = (reflection) => {
|
|
const parsedOptions = reflection
|
|
.parsedOptions;
|
|
return Array.isArray(parsedOptions)
|
|
? parsedOptions.filter((option) => Boolean(option) && typeof option === "object" && !Array.isArray(option))
|
|
: [];
|
|
};
|
|
const getReflectionOptions = (reflection, name) => {
|
|
const parsedValues = getParsedOptions(reflection).flatMap((option) => {
|
|
const value = getOption(option, name);
|
|
return value === undefined ? [] : [value];
|
|
});
|
|
if (parsedValues.length > 0) {
|
|
return parsedValues;
|
|
}
|
|
const value = getOption(reflection.options, name);
|
|
return value === undefined ? [] : [value];
|
|
};
|
|
const getReflectionOption = (reflection, name) => getReflectionOptions(reflection, name)[0];
|
|
const asObject = (value) => value && typeof value === "object" && !Array.isArray(value)
|
|
? value
|
|
: undefined;
|
|
const asArray = (value) => Array.isArray(value) ? value : value === undefined ? [] : [value];
|
|
const readString = (object, key) => {
|
|
const value = object?.[key];
|
|
return typeof value === "string" ? value : undefined;
|
|
};
|
|
const readNumber = (object, key) => {
|
|
const value = object?.[key];
|
|
return typeof value === "number" && Number.isFinite(value)
|
|
? value
|
|
: undefined;
|
|
};
|
|
const readBoolean = (object, key) => {
|
|
const value = object?.[key];
|
|
return typeof value === "boolean" ? value : undefined;
|
|
};
|
|
const symbolRefFromOption = (value, fallback) => {
|
|
const object = asObject(value);
|
|
if (!object) {
|
|
return fallback;
|
|
}
|
|
return {
|
|
namespace: readString(object, "namespace") ?? fallback.namespace,
|
|
name: readString(object, "name") ?? fallback.name,
|
|
version: readString(object, "version") ?? fallback.version,
|
|
...(readString(object, "hash")
|
|
? { hash: readString(object, "hash") }
|
|
: {}),
|
|
};
|
|
};
|
|
const typeRefFromOption = (value, fallbackVersion) => {
|
|
const object = asObject(value);
|
|
if (!object) {
|
|
return undefined;
|
|
}
|
|
const symbol = asObject(object.symbol)
|
|
? symbolRefFromOption(object.symbol, {
|
|
namespace: "",
|
|
name: "",
|
|
version: fallbackVersion,
|
|
})
|
|
: undefined;
|
|
const protoType = readString(object, "proto_type")?.replace(/^\./, "");
|
|
const symbolProtoType = symbol?.namespace && symbol.name ? `${symbol.namespace}.${symbol.name}` : "";
|
|
const resolvedProtoType = protoType ?? symbolProtoType;
|
|
if (!resolvedProtoType) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
protoType: resolvedProtoType,
|
|
...(symbol?.namespace && symbol.name ? { symbol } : {}),
|
|
};
|
|
};
|
|
const interfaceFieldContractFromOption = (value, schemaVersion) => {
|
|
const object = asObject(value);
|
|
if (!object) {
|
|
return undefined;
|
|
}
|
|
const type = typeRefFromOption(object.type, schemaVersion);
|
|
const refTargetTypeParam = readString(object, "ref_target_type_param");
|
|
const edgeCardinality = object.edge_cardinality === undefined
|
|
? undefined
|
|
: cardinalityFromOption(object.edge_cardinality);
|
|
return {
|
|
required: readBoolean(object, "required") ?? false,
|
|
readable: readBoolean(object, "readable") ?? false,
|
|
writable: readBoolean(object, "writable") ?? false,
|
|
watchable: readBoolean(object, "watchable") ?? false,
|
|
...(readString(object, "type_param")
|
|
? { typeParam: readString(object, "type_param") }
|
|
: {}),
|
|
...(type ? { type } : {}),
|
|
...(refTargetTypeParam ? { refTargetTypeParam } : {}),
|
|
...(edgeCardinality ? { edgeCardinality } : {}),
|
|
};
|
|
};
|
|
const functionRefFromOption = (value) => {
|
|
const object = asObject(value);
|
|
if (!object) {
|
|
throw new Error("Expected function ref option object");
|
|
}
|
|
const packageNamespace = readString(object, "package_namespace");
|
|
const packageName = readString(object, "package_name");
|
|
const symbol = readString(object, "symbol");
|
|
if (!packageNamespace || !packageName || !symbol) {
|
|
throw new Error("Function ref requires package_namespace, package_name, and symbol");
|
|
}
|
|
return {
|
|
packageNamespace,
|
|
packageName,
|
|
symbol,
|
|
...(readString(object, "operation")
|
|
? { operation: readString(object, "operation") }
|
|
: {}),
|
|
...(readString(object, "version_ref")
|
|
? { versionRef: readString(object, "version_ref") }
|
|
: {}),
|
|
};
|
|
};
|
|
const conflictFromOption = (value) => {
|
|
const normalized = enumName(value);
|
|
if (!value || normalized === "CONFLICT_STRATEGY_UNSPECIFIED") {
|
|
return "preserve_conflicts";
|
|
}
|
|
if (normalized === "REPLACE") {
|
|
return "replace";
|
|
}
|
|
if (normalized === "PRESERVE_CONFLICTS") {
|
|
return "preserve_conflicts";
|
|
}
|
|
if (normalized === "CRDT") {
|
|
return "crdt";
|
|
}
|
|
throw new Error(`Unknown Camino conflict strategy: ${String(value)}`);
|
|
};
|
|
const cardinalityFromOption = (value) => {
|
|
const normalized = lowerEnumName(value);
|
|
if (!value || normalized === "cardinality_unspecified") {
|
|
return "many";
|
|
}
|
|
if (normalized === "optional_one" ||
|
|
normalized === "exactly_one" ||
|
|
normalized === "many" ||
|
|
normalized === "many_unique" ||
|
|
normalized === "many_ordered" ||
|
|
normalized === "many_unique_ordered") {
|
|
return normalized;
|
|
}
|
|
throw new Error(`Unknown Camino cardinality: ${String(value)}`);
|
|
};
|
|
const fieldStorageKindFromOption = (value) => {
|
|
if (value === 5) {
|
|
return "static_final";
|
|
}
|
|
const normalized = lowerEnumName(value);
|
|
if (!value || normalized === "field_storage_kind_unspecified") {
|
|
return "stored";
|
|
}
|
|
if (normalized === "stored" ||
|
|
normalized === "derived" ||
|
|
normalized === "lazy" ||
|
|
normalized === "external" ||
|
|
normalized === "static_final") {
|
|
return normalized;
|
|
}
|
|
throw new Error(`Unknown Camino field storage kind: ${String(value)}`);
|
|
};
|
|
const fieldStorageFromOption = (value) => {
|
|
const object = asObject(value);
|
|
if (!object) {
|
|
return {
|
|
kind: "stored",
|
|
cache: false,
|
|
};
|
|
}
|
|
const kind = fieldStorageKindFromOption(object.kind);
|
|
const resolver = object.resolver === undefined
|
|
? undefined
|
|
: functionRefFromOption(object.resolver);
|
|
if ((kind === "derived" || kind === "lazy" || kind === "external") && !resolver) {
|
|
throw new Error(`${kind} field storage requires resolver`);
|
|
}
|
|
return {
|
|
kind,
|
|
...(resolver ? { resolver } : {}),
|
|
cache: object.cache === true,
|
|
};
|
|
};
|
|
const fieldOpsFromOption = (value) => {
|
|
const object = asObject(value);
|
|
if (!object) {
|
|
return undefined;
|
|
}
|
|
const implementation = enumName(object.implementation);
|
|
if (implementation !== "SERVICE") {
|
|
throw new Error(`Unsupported Camino field implementation: ${String(object.implementation)}`);
|
|
}
|
|
const service = readString(object, "service");
|
|
if (!service) {
|
|
throw new Error("SERVICE field_ops requires service");
|
|
}
|
|
return {
|
|
implementation: "service",
|
|
service,
|
|
};
|
|
};
|
|
const extractFileDefaults = (sourceFile) => {
|
|
const text = fs.readFileSync(sourceFile, "utf8");
|
|
const packageMatch = /^\s*package\s+([a-zA-Z0-9_.]+)\s*;/m.exec(text);
|
|
const namespaceMatch = /^\s*option\s+\(camino\.schema_namespace\)\s*=\s*"([^"]+)"\s*;/m.exec(text);
|
|
const versionMatch = /^\s*option\s+\(camino\.schema_version\)\s*=\s*"([^"]+)"\s*;/m.exec(text);
|
|
return {
|
|
packageName: packageMatch?.[1] ?? "",
|
|
schemaNamespace: namespaceMatch?.[1] ?? packageMatch?.[1] ?? "",
|
|
schemaVersion: versionMatch?.[1] ?? "1",
|
|
};
|
|
};
|
|
const protoSymbolFromReflection = (reflection, fallbackVersion) => {
|
|
if (!reflection?.fullName) {
|
|
return undefined;
|
|
}
|
|
const parts = reflection.fullName.replace(/^\./, "").split(".");
|
|
const name = parts.pop();
|
|
if (!name) {
|
|
return undefined;
|
|
}
|
|
return {
|
|
namespace: parts.join("."),
|
|
name,
|
|
version: fallbackVersion,
|
|
};
|
|
};
|
|
const typeRefForField = (field, schemaVersion) => {
|
|
const symbol = protoSymbolFromReflection(field.resolvedType, schemaVersion);
|
|
const enumValues = field.resolvedType instanceof protobuf.Enum
|
|
? Object.entries(field.resolvedType.values)
|
|
.filter(([_name, value]) => value !== 0)
|
|
.map(([name]) => name)
|
|
: undefined;
|
|
return {
|
|
protoType: field.type,
|
|
...(symbol ? { symbol } : {}),
|
|
...(enumValues ? { enumValues } : {}),
|
|
};
|
|
};
|
|
const typeRefForType = (type, fallbackType, schemaVersion) => {
|
|
const symbol = protoSymbolFromReflection(type ?? null, schemaVersion);
|
|
return {
|
|
protoType: type?.fullName?.replace(/^\./, "") ?? fallbackType,
|
|
...(symbol ? { symbol } : {}),
|
|
};
|
|
};
|
|
const fieldSchemaFromField = (field, schemaVersion) => {
|
|
const storage = fieldStorageFromOption(getReflectionOption(field, FIELD_STORAGE_OPTION));
|
|
const ops = fieldOpsFromOption(getReflectionOption(field, FIELD_OPS_OPTION));
|
|
if (ops && storage.kind !== "stored" && storage.kind !== "static_final") {
|
|
throw new Error(`Field ${field.fullName} cannot declare both field_ops and package-resolved storage`);
|
|
}
|
|
if (storage.kind === "static_final" && !ops) {
|
|
throw new Error(`Field ${field.fullName} with static_final storage requires field_ops`);
|
|
}
|
|
const interfaceContract = interfaceFieldContractFromOption(getReflectionOption(field, INTERFACE_FIELD_OPTION), schemaVersion);
|
|
return {
|
|
name: field.name,
|
|
tag: field.id,
|
|
type: typeRefForField(field, schemaVersion),
|
|
conflict: conflictFromOption(getReflectionOption(field, CONFLICT_OPTION)),
|
|
repeated: field.repeated,
|
|
optional: field.optional,
|
|
storage,
|
|
...(ops ? { ops } : {}),
|
|
...(interfaceContract ? { interfaceContract } : {}),
|
|
isDisplayLabel: getReflectionOption(field, DISPLAY_LABEL_OPTION) === true,
|
|
};
|
|
};
|
|
const isManyCardinality = (cardinality) => cardinality === "many" ||
|
|
cardinality === "many_unique" ||
|
|
cardinality === "many_ordered" ||
|
|
cardinality === "many_unique_ordered";
|
|
const materializationFromOption = (value, fallbackVersion) => {
|
|
const object = asObject(value);
|
|
if (!object) {
|
|
return undefined;
|
|
}
|
|
const classRef = symbolRefFromOption(object.class, {
|
|
namespace: "",
|
|
name: "",
|
|
version: fallbackVersion,
|
|
});
|
|
if (!classRef.name) {
|
|
throw new Error("Edge materialization requires class");
|
|
}
|
|
return {
|
|
class: classRef,
|
|
connectProjection: readString(object, "connect_projection") ?? "",
|
|
};
|
|
};
|
|
const endpointFromOption = (params) => {
|
|
const object = asObject(params.option);
|
|
const classRef = asObject(object?.class)
|
|
? symbolRefFromOption(object?.class, {
|
|
namespace: "",
|
|
name: "",
|
|
version: params.fallbackVersion,
|
|
})
|
|
: params.fallbackClass;
|
|
const interfaceRef = asObject(object?.interface)
|
|
? symbolRefFromOption(object?.interface, {
|
|
namespace: "",
|
|
name: "",
|
|
version: params.fallbackVersion,
|
|
})
|
|
: undefined;
|
|
const projection = readString(object, "projection") ?? params.fallbackProjection;
|
|
const cardinality = object?.cardinality === undefined
|
|
? params.fallbackCardinality
|
|
: cardinalityFromOption(object.cardinality);
|
|
const materialization = materializationFromOption(object?.materialize, params.fallbackVersion);
|
|
if (!classRef?.name && !interfaceRef?.name) {
|
|
throw new Error(`Edge endpoint ${projection} requires class or interface`);
|
|
}
|
|
return {
|
|
...(classRef?.name ? { class: classRef } : {}),
|
|
...(interfaceRef?.name ? { interface: interfaceRef } : {}),
|
|
projection,
|
|
cardinality,
|
|
indexed: readBoolean(object, "indexed") ?? false,
|
|
...(materialization ? { materialization } : {}),
|
|
};
|
|
};
|
|
const symbolKey = (ref) => ref ? [ref.namespace, ref.name, ref.version, ref.hash ?? ""].join(":") : "";
|
|
const endpointRoleKey = (endpoint) => [
|
|
symbolKey(endpoint.class),
|
|
symbolKey(endpoint.interface),
|
|
endpoint.projection,
|
|
endpoint.cardinality,
|
|
].join("|");
|
|
const normalizeEdgeEndpoints = (params) => {
|
|
if (params.directionality === "directed") {
|
|
return {
|
|
fromEndpoint: params.fromEndpoint,
|
|
toEndpoint: params.toEndpoint,
|
|
};
|
|
}
|
|
return endpointRoleKey(params.fromEndpoint) <= endpointRoleKey(params.toEndpoint)
|
|
? {
|
|
fromEndpoint: params.fromEndpoint,
|
|
toEndpoint: params.toEndpoint,
|
|
}
|
|
: {
|
|
fromEndpoint: params.toEndpoint,
|
|
toEndpoint: params.fromEndpoint,
|
|
};
|
|
};
|
|
const symbolArrayFromOption = (value, defaults) => asArray(value)
|
|
.map((item) => symbolRefFromOption(item, {
|
|
namespace: defaults.schemaNamespace,
|
|
name: "",
|
|
version: defaults.schemaVersion,
|
|
}))
|
|
.filter((symbol) => Boolean(symbol.name));
|
|
const isStringType = (type) => type.protoType === "string" || type.protoType === "google.protobuf.StringValue";
|
|
const validateDisplayLabelFields = (type, fields, operationServices) => {
|
|
const displayLabelFields = fields.filter((field) => field.isDisplayLabel);
|
|
if (displayLabelFields.length > 1) {
|
|
throw new Error(`Class ${type.fullName} declares multiple Camino display label fields`);
|
|
}
|
|
const displayLabelField = displayLabelFields[0];
|
|
if (!displayLabelField) {
|
|
return;
|
|
}
|
|
if (!displayLabelField.ops) {
|
|
if (!isStringType(displayLabelField.type)) {
|
|
throw new Error(`Display label field ${type.fullName}.${displayLabelField.name} must have string type`);
|
|
}
|
|
return;
|
|
}
|
|
const serviceName = displayLabelField.ops.service.replace(/^\./, "");
|
|
const getOperation = operationServices
|
|
.find((service) => service.fullName === serviceName)
|
|
?.operations.find((operation) => operation.name === "Get");
|
|
if (!getOperation || !isStringType(getOperation.outputType)) {
|
|
throw new Error(`Display label field ${type.fullName}.${displayLabelField.name} must resolve to string`);
|
|
}
|
|
};
|
|
const typeBindingFromOption = (value, schemaVersion) => {
|
|
const object = asObject(value);
|
|
if (!object) {
|
|
return undefined;
|
|
}
|
|
const name = readString(object, "name");
|
|
const type = typeRefFromOption(object.type, schemaVersion);
|
|
if (!name || !type) {
|
|
throw new Error("Interface type_binding requires name and type");
|
|
}
|
|
return { name, type };
|
|
};
|
|
const interfaceImplementationFromOption = (value, defaults) => {
|
|
const object = asObject(value);
|
|
if (!object) {
|
|
throw new Error("Expected implements option object");
|
|
}
|
|
const interfaceId = symbolRefFromOption(object.interface, {
|
|
namespace: defaults.schemaNamespace,
|
|
name: "",
|
|
version: defaults.schemaVersion,
|
|
});
|
|
if (!interfaceId.name) {
|
|
throw new Error("implements option requires interface name");
|
|
}
|
|
return {
|
|
interface: interfaceId,
|
|
typeBindings: asArray(object.type_binding)
|
|
.map((binding) => typeBindingFromOption(binding, defaults.schemaVersion))
|
|
.filter((binding) => Boolean(binding)),
|
|
};
|
|
};
|
|
const serviceName = (service) => service.fullName.replace(/^\./, "");
|
|
const operationFromMethod = (method, schemaVersion) => {
|
|
const fn = functionRefFromOption(getReflectionOption(method, IMPL_OPTION));
|
|
const methodWithTypes = method;
|
|
return {
|
|
name: method.name,
|
|
inputType: typeRefForType(methodWithTypes.resolvedRequestType, method.requestType, schemaVersion),
|
|
outputType: typeRefForType(methodWithTypes.resolvedResponseType, method.responseType, schemaVersion),
|
|
function: fn,
|
|
};
|
|
};
|
|
const operationServiceFromService = (service, schemaVersion) => ({
|
|
name: service.name,
|
|
fullName: serviceName(service),
|
|
operations: service.methodsArray.map((method) => operationFromMethod(method, schemaVersion)),
|
|
});
|
|
const classSchemaFromType = (type, sourceFile, defaults, operationServices) => {
|
|
const classOption = asObject(getReflectionOption(type, CLASS_OPTION));
|
|
if (!classOption) {
|
|
return undefined;
|
|
}
|
|
const fallbackClassRef = {
|
|
namespace: defaults.schemaNamespace,
|
|
name: type.name,
|
|
version: defaults.schemaVersion,
|
|
};
|
|
const classId = symbolRefFromOption(classOption.id, fallbackClassRef);
|
|
const classVersion = readNumber(classOption, "version") ?? Number(classId.version);
|
|
if (!Number.isInteger(classVersion) || classVersion <= 0) {
|
|
throw new Error(`Class ${type.fullName} has invalid Camino class version`);
|
|
}
|
|
const fields = type.fieldsArray.map((field) => fieldSchemaFromField(field, defaults.schemaVersion));
|
|
validateDisplayLabelFields(type, fields, operationServices);
|
|
const edges = type.fieldsArray.flatMap((field) => {
|
|
const edgeOption = asObject(getReflectionOption(field, EDGE_OPTION));
|
|
if (!edgeOption) {
|
|
return [];
|
|
}
|
|
const legacyTargetClass = symbolRefFromOption(edgeOption.target, {
|
|
namespace: defaults.schemaNamespace,
|
|
name: "",
|
|
version: defaults.schemaVersion,
|
|
});
|
|
const cardinality = cardinalityFromOption(edgeOption.cardinality);
|
|
const declaredFromEndpoint = endpointFromOption({
|
|
option: edgeOption.this_endpoint,
|
|
fallbackClass: classId,
|
|
fallbackProjection: field.name,
|
|
fallbackCardinality: cardinality,
|
|
fallbackVersion: defaults.schemaVersion,
|
|
});
|
|
const declaredToEndpoint = endpointFromOption({
|
|
option: edgeOption.other_endpoint,
|
|
fallbackClass: legacyTargetClass.name ? legacyTargetClass : undefined,
|
|
fallbackProjection: readString(edgeOption, "inverse") ?? "",
|
|
fallbackCardinality: "many",
|
|
fallbackVersion: defaults.schemaVersion,
|
|
});
|
|
if (!declaredToEndpoint.projection) {
|
|
throw new Error(`Edge field ${type.fullName}.${field.name} requires other_endpoint.projection or inverse`);
|
|
}
|
|
const sourceType = typeRefForField(field, defaults.schemaVersion);
|
|
if (sourceType.symbol?.namespace !== "camino" ||
|
|
sourceType.symbol.name !== "Ref") {
|
|
throw new Error(`Edge field ${type.fullName}.${field.name} must use camino.Ref`);
|
|
}
|
|
const isMany = isManyCardinality(declaredFromEndpoint.cardinality);
|
|
if (isMany && !field.repeated) {
|
|
throw new Error(`Edge field ${type.fullName}.${field.name} must be repeated for ${declaredFromEndpoint.cardinality}`);
|
|
}
|
|
if (!isMany && field.repeated) {
|
|
throw new Error(`Edge field ${type.fullName}.${field.name} must not be repeated for ${declaredFromEndpoint.cardinality}`);
|
|
}
|
|
const directionality = edgeOption.undirected === true ? "undirected" : "directed";
|
|
const { fromEndpoint, toEndpoint } = normalizeEdgeEndpoints({
|
|
directionality,
|
|
fromEndpoint: declaredFromEndpoint,
|
|
toEndpoint: declaredToEndpoint,
|
|
});
|
|
const edgeId = symbolRefFromOption(edgeOption.id, {
|
|
namespace: defaults.schemaNamespace,
|
|
name: `${type.name}.${field.name}`,
|
|
version: defaults.schemaVersion,
|
|
});
|
|
const fallbackToClass = toEndpoint.class ?? {
|
|
namespace: "",
|
|
name: "",
|
|
version: "",
|
|
};
|
|
return [
|
|
{
|
|
id: edgeId,
|
|
directionality,
|
|
sourceField: fromEndpoint.projection,
|
|
fromClass: fromEndpoint.class ?? classId,
|
|
toClass: fallbackToClass,
|
|
cardinality: fromEndpoint.cardinality,
|
|
...(toEndpoint.projection
|
|
? { inverse: toEndpoint.projection }
|
|
: {}),
|
|
fields: [],
|
|
fromEndpoint,
|
|
toEndpoint,
|
|
declaringClass: classId,
|
|
tags: symbolArrayFromOption(edgeOption.tag, defaults),
|
|
implements: symbolArrayFromOption(edgeOption.implements, defaults),
|
|
},
|
|
];
|
|
});
|
|
const methods = getReflectionOptions(type, METHOD_OPTION)
|
|
.map(asObject)
|
|
.filter((option) => Boolean(option))
|
|
.map((option) => {
|
|
const name = readString(option, "name");
|
|
if (!name) {
|
|
throw new Error(`Method option on ${type.fullName} requires name`);
|
|
}
|
|
return {
|
|
name,
|
|
function: functionRefFromOption(option.function),
|
|
};
|
|
});
|
|
const migrations = getReflectionOptions(type, MIGRATION_OPTION)
|
|
.map(asObject)
|
|
.filter((option) => Boolean(option))
|
|
.map((option) => {
|
|
const fromVersion = readNumber(option, "from_version");
|
|
const toVersion = readNumber(option, "to_version");
|
|
if (!fromVersion || !toVersion) {
|
|
throw new Error(`Migration option on ${type.fullName} requires versions`);
|
|
}
|
|
return {
|
|
fromVersion,
|
|
toVersion,
|
|
function: functionRefFromOption(option.function),
|
|
};
|
|
});
|
|
const constructorOption = asObject(getReflectionOption(type, CONSTRUCTOR_OPTION));
|
|
const constructorSpec = constructorOption
|
|
? { function: functionRefFromOption(constructorOption.function) }
|
|
: undefined;
|
|
return {
|
|
id: classId,
|
|
version: classVersion,
|
|
fields,
|
|
edges,
|
|
methods,
|
|
migrations,
|
|
operationServices,
|
|
implements: getReflectionOptions(type, IMPLEMENTS_OPTION).map((option) => interfaceImplementationFromOption(option, defaults)),
|
|
...(constructorSpec ? { constructorSpec } : {}),
|
|
sourceFile,
|
|
protoMessage: type.fullName,
|
|
};
|
|
};
|
|
const interfaceSchemaFromType = (type, sourceFile, defaults) => {
|
|
const interfaceOption = asObject(getReflectionOption(type, INTERFACE_OPTION));
|
|
if (!interfaceOption) {
|
|
return undefined;
|
|
}
|
|
const classOption = asObject(getReflectionOption(type, CLASS_OPTION));
|
|
if (classOption) {
|
|
throw new Error(`${type.fullName} cannot be both a Camino class and interface`);
|
|
}
|
|
const fallbackInterfaceRef = {
|
|
namespace: defaults.schemaNamespace,
|
|
name: type.name,
|
|
version: defaults.schemaVersion,
|
|
};
|
|
const interfaceId = symbolRefFromOption(interfaceOption.id, fallbackInterfaceRef);
|
|
const interfaceVersion = readNumber(interfaceOption, "version") ?? Number(interfaceId.version);
|
|
if (!Number.isInteger(interfaceVersion) || interfaceVersion <= 0) {
|
|
throw new Error(`Interface ${type.fullName} has invalid Camino interface version`);
|
|
}
|
|
return {
|
|
id: interfaceId,
|
|
version: interfaceVersion,
|
|
fields: type.fieldsArray.map((field) => fieldSchemaFromField(field, defaults.schemaVersion)),
|
|
sourceFile,
|
|
protoMessage: type.fullName,
|
|
typeParameters: asArray(interfaceOption.type_parameter)
|
|
.map((entry) => String(entry).trim())
|
|
.filter(Boolean),
|
|
};
|
|
};
|
|
const walkTypes = (namespace, visit) => {
|
|
for (const nested of namespace.nestedArray) {
|
|
if (nested instanceof protobuf.Type) {
|
|
visit(nested);
|
|
walkTypes(nested, visit);
|
|
}
|
|
else if (nested instanceof protobuf.Namespace) {
|
|
walkTypes(nested, visit);
|
|
}
|
|
}
|
|
};
|
|
const walkServices = (namespace, visit) => {
|
|
for (const nested of namespace.nestedArray) {
|
|
if (nested instanceof protobuf.Service) {
|
|
visit(nested);
|
|
}
|
|
else if (nested instanceof protobuf.Namespace) {
|
|
walkServices(nested, visit);
|
|
}
|
|
}
|
|
};
|
|
export const loadCaminoSchemaRoot = async (sourceFile) => {
|
|
const absoluteSourceFile = path.resolve(sourceFile);
|
|
const root = new protobuf.Root();
|
|
const envIncludeDirs = [
|
|
process.env.QUIXOS_PROTO_PATH,
|
|
process.env.CAMINO_PROTO_PATH,
|
|
]
|
|
.filter((value) => Boolean(value))
|
|
.join(path.delimiter)
|
|
.split(path.delimiter)
|
|
.map((entry) => entry.trim())
|
|
.filter(Boolean)
|
|
.map((entry) => path.resolve(entry));
|
|
const includeDirs = [
|
|
path.dirname(absoluteSourceFile),
|
|
path.resolve("../../quixos-protocol/proto"),
|
|
path.resolve("quixos-protocol/proto"),
|
|
path.resolve("proto"),
|
|
...envIncludeDirs,
|
|
];
|
|
root.resolvePath = (origin, target) => {
|
|
if (path.isAbsolute(target) && fs.existsSync(target)) {
|
|
return target;
|
|
}
|
|
const originDir = origin ? path.dirname(origin) : undefined;
|
|
const candidates = [
|
|
...(originDir ? [path.resolve(originDir, target)] : []),
|
|
...includeDirs.map((dir) => path.resolve(dir, target)),
|
|
];
|
|
const found = candidates.find((candidate) => fs.existsSync(candidate));
|
|
return found ?? target;
|
|
};
|
|
await root.load(absoluteSourceFile, { keepCase: true });
|
|
root.resolveAll();
|
|
return { absoluteSourceFile, root };
|
|
};
|
|
export const compileCaminoSchema = async (sourceFile) => {
|
|
const { absoluteSourceFile, root } = await loadCaminoSchemaRoot(sourceFile);
|
|
const defaults = extractFileDefaults(absoluteSourceFile);
|
|
const operationServices = [];
|
|
walkServices(root, (service) => {
|
|
operationServices.push(operationServiceFromService(service, defaults.schemaVersion));
|
|
});
|
|
const classes = [];
|
|
const interfaces = [];
|
|
walkTypes(root, (type) => {
|
|
const interfaceSchema = interfaceSchemaFromType(type, absoluteSourceFile, defaults);
|
|
if (interfaceSchema) {
|
|
interfaces.push(interfaceSchema);
|
|
}
|
|
const classSchema = classSchemaFromType(type, absoluteSourceFile, defaults, operationServices);
|
|
if (classSchema) {
|
|
classes.push(classSchema);
|
|
}
|
|
});
|
|
return {
|
|
sourceFile: absoluteSourceFile,
|
|
classes,
|
|
interfaces,
|
|
};
|
|
};
|