2026-09-17 14:34:16 -07:00
|
|
|
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) },
|
2026-09-17 14:34:16 -07:00
|
|
|
...(member.keyType
|
|
|
|
|
? {
|
|
|
|
|
mapKey: {
|
|
|
|
|
type: new GraphQLNonNull(
|
|
|
|
|
queryScalars[member.keyType === "boolean" ? "bool" : member.keyType],
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
2026-09-17 14:34:16 -07:00
|
|
|
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: {
|
2026-09-17 14:34:16 -07:00
|
|
|
first: { type: GraphQLInt },
|
|
|
|
|
all: { type: GraphQLInt },
|
2026-09-17 14:34:16 -07:00
|
|
|
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 };
|
|
|
|
|
}
|