2dffdbcd9f
Add native register acknowledgments and idempotent mutation replay, shared optimistic field controllers, overlapping custom setters, non-suspending hooks and explicit Suspense. Carry checked @live provenance through batched queries and hydrate shared browser fields with coverage leases. Update tracker/scaffolds/guides and verify compiler, PostgreSQL, browser and immutable workspace paths.
255 lines
10 KiB
TypeScript
255 lines
10 KiB
TypeScript
import {
|
|
GraphQLBoolean,
|
|
GraphQLString,
|
|
GraphQLInt,
|
|
GraphQLFloat,
|
|
GraphQLScalarType,
|
|
GraphQLObjectType,
|
|
GraphQLInputObjectType,
|
|
GraphQLEnumType,
|
|
GraphQLList,
|
|
GraphQLNonNull,
|
|
GraphQLSchema,
|
|
GraphQLDirective,
|
|
DirectiveLocation,
|
|
specifiedDirectives,
|
|
type GraphQLOutputType,
|
|
type GraphQLInputType,
|
|
type GraphQLFieldConfigMap,
|
|
type GraphQLInputFieldConfigMap,
|
|
type DocumentNode,
|
|
} 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";
|
|
import { relationalSchema, objectRow } from "./relational-schema.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 function querySchema(
|
|
declaration: QueryDeclaration,
|
|
interfaces: readonly InterfaceRevision[],
|
|
document?: DocumentNode,
|
|
) {
|
|
const contracts = new Map(interfaces.map((entry) => [entry.revisionId, entry]));
|
|
const objects = new Map<InterfaceRevisionId, GraphQLObjectType>();
|
|
const orders = new Map<InterfaceRevisionId, GraphQLInputObjectType>();
|
|
const byName = new Map<string, InterfaceRevision>();
|
|
const comparisons = new Map<string, GraphQLInputObjectType>();
|
|
const scalarTypes = new Map<string, ValueType>(
|
|
Object.entries(queryScalars).map(([name, type]) => [type.name, { kind: "scalar", name } as ValueType]),
|
|
);
|
|
scalarTypes.set("Cursor", { kind: "scalar", name: "string" });
|
|
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 !== "bytes")
|
|
fields.in = { type: new GraphQLList(new GraphQLNonNull(value)) };
|
|
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 relational = relationalSchema(
|
|
{ contracts, target, name, scalar, comparison, scalarTypes, direction, cursor: cursorScalar, pageInfo },
|
|
document,
|
|
);
|
|
relational.discover(declaration.root);
|
|
const filter = (id: InterfaceRevisionId) => relational.where(objectRow(id));
|
|
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 helpers = relational.helpers(id);
|
|
const metadata = new GraphQLObjectType({
|
|
name: `QxMetadata_${name(id)}`,
|
|
fields: {
|
|
ref: { type: new GraphQLNonNull(relational.refs(id)) },
|
|
...(helpers ? { relations: { type: new GraphQLNonNull(helpers) } } : {}),
|
|
},
|
|
});
|
|
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) },
|
|
...(member.keyType
|
|
? {
|
|
mapKey: {
|
|
type: new GraphQLNonNull(
|
|
queryScalars[member.keyType === "boolean" ? "bool" : member.keyType],
|
|
),
|
|
},
|
|
}
|
|
: {}),
|
|
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: GraphQLInt },
|
|
all: { type: 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({
|
|
directives: [...specifiedDirectives, new GraphQLDirective({ name: "live", locations: [DirectiveLocation.FIELD] })],
|
|
query: new GraphQLObjectType({
|
|
name: "QxQuery",
|
|
fields: { root: { type: new GraphQLNonNull(object(declaration.root)) } },
|
|
}),
|
|
});
|
|
return { schema, byName, target, contracts, relational, scalarTypes };
|
|
}
|