Add queryable capability contracts and checked GraphQL artifacts
This commit is contained in:
@@ -0,0 +1,243 @@
|
||||
import {
|
||||
GraphQLBoolean,
|
||||
GraphQLString,
|
||||
GraphQLInt,
|
||||
GraphQLFloat,
|
||||
GraphQLScalarType,
|
||||
GraphQLObjectType,
|
||||
GraphQLInputObjectType,
|
||||
GraphQLEnumType,
|
||||
GraphQLList,
|
||||
GraphQLNonNull,
|
||||
GraphQLSchema,
|
||||
type GraphQLOutputType,
|
||||
type GraphQLInputType,
|
||||
type GraphQLFieldConfigMap,
|
||||
type GraphQLInputFieldConfigMap,
|
||||
} from "graphql";
|
||||
import { createHash } from "node:crypto";
|
||||
import type {
|
||||
InterfaceRevision,
|
||||
InterfaceRevisionId,
|
||||
ValueType,
|
||||
RelationshipInterfaceMember,
|
||||
} from "../capability-model/types.js";
|
||||
import { QueryCompileError, type QueryDeclaration } from "./types.js";
|
||||
|
||||
// Validate losslessly; callers carry decimal strings across JSON transports.
|
||||
function integerParser(name: string, min: bigint, max: bigint, value: unknown): string {
|
||||
if (
|
||||
typeof value !== "string" &&
|
||||
typeof value !== "bigint" &&
|
||||
!(typeof value === "number" && Number.isSafeInteger(value))
|
||||
)
|
||||
throw new Error(`${name} requires a canonical integer`);
|
||||
const text = String(value);
|
||||
if (!/^-?(0|[1-9][0-9]*)$/.test(text) || text === "-0") throw new Error(`${name} requires a canonical integer`);
|
||||
const n = BigInt(text);
|
||||
if (n < min || n > max) throw new Error(`${name} out of range`);
|
||||
return text;
|
||||
}
|
||||
const integerScalar = (name: string, min: bigint, max: bigint) => {
|
||||
const parse = (v: unknown) => integerParser(name, min, max, v);
|
||||
return new GraphQLScalarType({
|
||||
name,
|
||||
serialize: parse,
|
||||
parseValue: parse,
|
||||
parseLiteral(node) {
|
||||
if (node.kind !== "IntValue" && node.kind !== "StringValue") throw new Error(`${name} requires an integer`);
|
||||
return parse(node.value);
|
||||
},
|
||||
});
|
||||
};
|
||||
export const queryScalars = {
|
||||
bool: GraphQLBoolean,
|
||||
string: GraphQLString,
|
||||
int32: GraphQLInt,
|
||||
int64: integerScalar("Int64", -(1n << 63n), (1n << 63n) - 1n),
|
||||
uint32: integerScalar("UInt32", 0n, (1n << 32n) - 1n),
|
||||
uint64: integerScalar("UInt64", 0n, (1n << 64n) - 1n),
|
||||
double: GraphQLFloat,
|
||||
bytes: new GraphQLScalarType({ name: "Bytes" }),
|
||||
};
|
||||
export const cursorScalar = new GraphQLScalarType({
|
||||
name: "Cursor",
|
||||
parseValue(value) {
|
||||
if (typeof value !== "string" || value.length > 4096) throw new Error("Invalid cursor");
|
||||
return value;
|
||||
},
|
||||
});
|
||||
export const referenceScalar = new GraphQLScalarType({ name: "ManagedReference" });
|
||||
|
||||
export function querySchema(declaration: QueryDeclaration, interfaces: readonly InterfaceRevision[]) {
|
||||
const contracts = new Map(interfaces.map((entry) => [entry.revisionId, entry]));
|
||||
const objects = new Map<InterfaceRevisionId, GraphQLObjectType>();
|
||||
const filters = new Map<InterfaceRevisionId, GraphQLInputObjectType>();
|
||||
const orders = new Map<InterfaceRevisionId, GraphQLInputObjectType>();
|
||||
const byName = new Map<string, InterfaceRevision>();
|
||||
const comparisons = new Map<string, GraphQLInputObjectType>();
|
||||
const metadata = new GraphQLObjectType({
|
||||
name: "QxMetadata",
|
||||
fields: { ref: { type: new GraphQLNonNull(referenceScalar) } },
|
||||
});
|
||||
const pageInfo = new GraphQLObjectType({
|
||||
name: "QxPageInfo",
|
||||
fields: {
|
||||
hasNextPage: { type: new GraphQLNonNull(GraphQLBoolean) },
|
||||
endCursor: { type: cursorScalar },
|
||||
},
|
||||
});
|
||||
const direction = new GraphQLEnumType({ name: "QxDirection", values: { ASC: {}, DESC: {} } });
|
||||
const contract = (id: InterfaceRevisionId) => {
|
||||
const found = contracts.get(id);
|
||||
if (!found || found.template) throw new QueryCompileError("QUERY_CONTRACT", `Expected closed interface ${id}`);
|
||||
return found;
|
||||
};
|
||||
const name = (id: InterfaceRevisionId) => {
|
||||
const revision = contract(id);
|
||||
const result =
|
||||
revision.displayName +
|
||||
(revision.application ? `_${createHash("sha256").update(id).digest("hex").slice(0, 12)}` : "");
|
||||
if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(result) || result.startsWith("__") || result.startsWith("Qx"))
|
||||
throw new QueryCompileError("QUERY_SCHEMA_NAME", `Unsupported query interface name ${result}`);
|
||||
const existing = byName.get(result);
|
||||
if (existing && existing.revisionId !== id)
|
||||
throw new QueryCompileError("QUERY_SCHEMA_NAME", `Conflicting query interface name ${result}`);
|
||||
byName.set(result, revision);
|
||||
return result;
|
||||
};
|
||||
const target = (member: RelationshipInterfaceMember): InterfaceRevisionId => {
|
||||
if (member.target.kind === "interface") return member.target.interfaceRevisionId;
|
||||
const atomId = member.target.atomId;
|
||||
const views = declaration.views.filter((entry) => entry.atomId === atomId);
|
||||
if (views.length !== 1)
|
||||
throw new QueryCompileError(
|
||||
"QUERY_VIEW_REQUIRED",
|
||||
`Declare exactly one interface view for ${member.target.atomId}`,
|
||||
);
|
||||
return views[0]!.interfaceRevisionId;
|
||||
};
|
||||
const scalar = (type: ValueType): GraphQLOutputType & GraphQLInputType => {
|
||||
if (type.kind === "optional") return scalar(type.value);
|
||||
if (type.kind !== "scalar")
|
||||
throw new QueryCompileError("QUERY_TYPE", "Only scalar/optional scalar query fields are supported");
|
||||
return queryScalars[type.name];
|
||||
};
|
||||
const comparison = (type: ValueType) => {
|
||||
const base = type.kind === "optional" ? type.value : type;
|
||||
const value = scalar(base);
|
||||
const key = String(value);
|
||||
let result = comparisons.get(key);
|
||||
if (!result) {
|
||||
const fields: GraphQLInputFieldConfigMap = { eq: { type: value }, isNull: { type: GraphQLBoolean } };
|
||||
if (base.kind === "scalar" && base.name !== "bool" && base.name !== "bytes")
|
||||
for (const op of ["lt", "lte", "gt", "gte"]) fields[op] = { type: value };
|
||||
result = new GraphQLInputObjectType({ name: `QxCompare${key}`, fields });
|
||||
comparisons.set(key, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const filter = (id: InterfaceRevisionId): GraphQLInputObjectType => {
|
||||
const existing = filters.get(id);
|
||||
if (existing) return existing;
|
||||
const result = new GraphQLInputObjectType({
|
||||
name: `${name(id)}Where`,
|
||||
fields: () => {
|
||||
const fields: GraphQLInputFieldConfigMap = {
|
||||
and: { type: new GraphQLList(new GraphQLNonNull(result)) },
|
||||
or: { type: new GraphQLList(new GraphQLNonNull(result)) },
|
||||
not: { type: result },
|
||||
};
|
||||
for (const member of contract(id).members)
|
||||
if (member.kind === "value" && member.queryRead) {
|
||||
if (member.displayName in fields)
|
||||
throw new QueryCompileError("QUERY_SCHEMA_NAME", `Reserved predicate name ${member.displayName}`);
|
||||
fields[member.displayName] = { type: comparison(member.valueType) };
|
||||
}
|
||||
return fields;
|
||||
},
|
||||
});
|
||||
filters.set(id, result);
|
||||
return result;
|
||||
};
|
||||
const order = (id: InterfaceRevisionId) => {
|
||||
let result = orders.get(id);
|
||||
if (!result) {
|
||||
const fields: GraphQLInputFieldConfigMap = {};
|
||||
for (const member of contract(id).members) {
|
||||
if (member.kind !== "value" || !member.queryRead) continue;
|
||||
const t = member.valueType.kind === "optional" ? member.valueType.value : member.valueType;
|
||||
if (t.kind === "scalar" && t.name !== "bytes") fields[member.displayName] = { type: direction };
|
||||
}
|
||||
if (Object.keys(fields).length === 0) return undefined;
|
||||
result = new GraphQLInputObjectType({ name: `${name(id)}Order`, fields });
|
||||
orders.set(id, result);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
const object = (id: InterfaceRevisionId): GraphQLObjectType => {
|
||||
const existing = objects.get(id);
|
||||
if (existing) return existing;
|
||||
const result = new GraphQLObjectType({
|
||||
name: name(id),
|
||||
fields: () => {
|
||||
const fields: GraphQLFieldConfigMap<unknown, unknown> = { _qx: { type: new GraphQLNonNull(metadata) } };
|
||||
for (const member of contract(id).members) {
|
||||
if (member.kind === "operation" || !member.queryRead) continue;
|
||||
if (!/^[_A-Za-z][_0-9A-Za-z]*$/.test(member.displayName) || member.displayName.startsWith("_"))
|
||||
throw new QueryCompileError("QUERY_SCHEMA_NAME", `Unsupported query field name ${member.displayName}`);
|
||||
if (member.kind === "value") {
|
||||
const type = scalar(member.valueType);
|
||||
fields[member.displayName] = {
|
||||
type: member.valueType.kind === "optional" ? type : new GraphQLNonNull(type),
|
||||
};
|
||||
} else {
|
||||
const targetId = target(member),
|
||||
node = object(targetId);
|
||||
if (member.cardinality === "exactly-one" || member.cardinality === "optional-one")
|
||||
fields[member.displayName] = {
|
||||
type: member.cardinality === "exactly-one" ? new GraphQLNonNull(node) : node,
|
||||
};
|
||||
else {
|
||||
const entry = new GraphQLObjectType({
|
||||
name: `${name(id)}_${member.displayName}_Entry`,
|
||||
fields: {
|
||||
key: { type: new GraphQLNonNull(GraphQLString) },
|
||||
cursor: { type: cursorScalar },
|
||||
node: { type: new GraphQLNonNull(node) },
|
||||
},
|
||||
});
|
||||
const connection = new GraphQLObjectType({
|
||||
name: `${name(id)}_${member.displayName}_Connection`,
|
||||
fields: {
|
||||
entries: { type: new GraphQLNonNull(new GraphQLList(new GraphQLNonNull(entry))) },
|
||||
pageInfo: { type: new GraphQLNonNull(pageInfo) },
|
||||
},
|
||||
});
|
||||
const ordering = order(targetId);
|
||||
fields[member.displayName] = {
|
||||
type: new GraphQLNonNull(connection),
|
||||
args: {
|
||||
first: { type: new GraphQLNonNull(GraphQLInt) },
|
||||
after: { type: cursorScalar },
|
||||
where: { type: filter(targetId) },
|
||||
...(ordering ? { orderBy: { type: new GraphQLList(new GraphQLNonNull(ordering)) } } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
},
|
||||
});
|
||||
objects.set(id, result);
|
||||
return result;
|
||||
};
|
||||
const schema = new GraphQLSchema({
|
||||
query: new GraphQLObjectType({
|
||||
name: "QxQuery",
|
||||
fields: { root: { type: new GraphQLNonNull(object(declaration.root)) } },
|
||||
}),
|
||||
});
|
||||
return { schema, byName, target, contracts };
|
||||
}
|
||||
Reference in New Issue
Block a user