Add queryable capability contracts and checked GraphQL artifacts

This commit is contained in:
Timothy J. Aveni
2026-09-17 14:34:16 -07:00
parent e3501772c5
commit 61a410f98f
27 changed files with 4798 additions and 2333 deletions
+243
View File
@@ -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 };
}