Add explicit CRDT edit contracts and structured query values

This commit is contained in:
Timothy J. Aveni
2026-09-19 22:33:20 -07:00
parent 0555c929b1
commit 4c0c404a39
27 changed files with 3272 additions and 2745 deletions
+47 -12
View File
@@ -154,8 +154,19 @@ export function relationalCompiler(
): QueryExpression => {
requireAvailable(names, available, node);
const meta = names.at(-2) === "_qx";
const { row, path, nullable } = walk(root, names.slice(0, meta ? -2 : -1), uses, node);
const name = names.at(-1)!;
let fieldIndex = names.length - 1;
if (!meta) {
let cursor = root;
for (let index = 0; index < names.length; index++) {
if (cursor.kind === "object" && rowFields(cursor).get(names[index]!)?.kind === "leaf") {
fieldIndex = index;
break;
}
if (index < names.length - 1) cursor = walk(root, names.slice(0, index + 1), uses, node).row;
}
}
const { row, path, nullable } = walk(root, names.slice(0, meta ? -2 : fieldIndex), uses, node);
const name = names[meta ? names.length - 1 : fieldIndex]!;
let type: ValueType, leaf: QueryExpression["leaf"];
if (meta) {
if (name === "ref" && row.kind === "object") {
@@ -185,7 +196,24 @@ export function relationalCompiler(
for (const use of uses) mark(row.interfaceRevisionId, name, use, node);
const member = env.contracts.get(row.interfaceRevisionId)!.members.find((m) => m.displayName === name)!;
type = field.type;
leaf = { kind: "field", interfaceRevisionId: row.interfaceRevisionId, memberId: member.id };
const valuePath = names.slice(fieldIndex + 1);
let valueNullable = false;
for (const component of valuePath) {
if (type.kind === "optional") {
valueNullable = true;
type = type.value;
}
if (type.kind !== "record" || !Object.hasOwn(type.fields, component))
return fail("QUERY_FIELD_NOT_QUERYABLE", `Unknown scalar value path ${names.join(".")}`, node);
type = type.fields[component]!;
}
if (valueNullable && type.kind !== "optional") type = valueType.optional(type);
leaf = {
kind: "field",
interfaceRevisionId: row.interfaceRevisionId,
memberId: member.id,
...(valuePath.length ? { valuePath } : {}),
};
}
return { path, leaf, type: nullable && type.kind !== "optional" ? valueType.optional(type) : type };
};
@@ -356,15 +384,22 @@ export function relationalCompiler(
}
}
} else {
const expr = expression(row, [...base, name], ["predicate"], field, available);
children.push(
...(comparisons(field.value, (operator, value) => ({
kind: "compare",
expression: expr,
operator,
value,
})) as QueryPredicate[]),
);
const valuePredicate = (names: string[], value: ValueNode) => {
const expr = expression(row, names, ["predicate"], field, available);
const type = expr.type.kind === "optional" ? expr.type.value : expr.type;
if (type.kind === "record") {
for (const child of pairs(value)) valuePredicate([...names, child.name.value], child.value);
} else
children.push(
...(comparisons(value, (operator, value) => ({
kind: "compare",
expression: expr,
operator,
value,
})) as QueryPredicate[]),
);
};
valuePredicate([...base, name], field.value);
}
}
};
+5 -1
View File
@@ -1,3 +1,4 @@
import { comparableValue } from "./structured.js";
import {
GraphQLBoolean,
GraphQLString,
@@ -45,6 +46,7 @@ export interface RelationalSchemaEnvironment {
name(id: InterfaceRevisionId): string;
scalar(type: ValueType): GraphQLInputType & GraphQLOutputType;
comparison(type: ValueType): GraphQLInputObjectType;
valueOrder(type: ValueType): GraphQLInputType;
scalarTypes: Map<string, ValueType>;
direction: GraphQLEnumType;
cursor: GraphQLScalarType;
@@ -255,7 +257,8 @@ export function relationalSchema(env: RelationalSchemaEnvironment, document?: Do
continue;
}
}
result[name] = { type: ordering ? env.direction : compare(type) };
if (!comparableValue(type, ordering)) continue;
result[name] = { type: ordering ? env.valueOrder(type) : compare(type) };
}
}
const meta: GraphQLInputFieldConfigMap = {};
@@ -300,6 +303,7 @@ export function relationalSchema(env: RelationalSchemaEnvironment, document?: Do
for (const [name, field] of fields(row)) {
if (row.kind === "object" && field.kind === "row") continue;
if (name in result) throw new QueryCompileError("QUERY_SCHEMA_NAME", `Reserved predicate name ${name}`);
if (field.kind === "leaf" && !comparableValue(field.type)) continue;
result[name] = { type: field.kind === "row" ? where(field.row) : compare(field.type) };
}
result._qx = {
+1 -1
View File
@@ -49,7 +49,7 @@ export type QueryPathStep =
export interface QueryExpression {
path: QueryPathStep[];
leaf:
| { kind: "field"; interfaceRevisionId: InterfaceRevisionId; memberId: MemberId }
| { kind: "field"; interfaceRevisionId: InterfaceRevisionId; memberId: MemberId; valuePath?: string[] }
| { kind: "ref"; interfaceRevisionId: InterfaceRevisionId }
| { kind: "entry" | "mapKey" };
type: ValueType;
+18 -7
View File
@@ -1,3 +1,4 @@
import { structuredQueryTypes, comparableValue } from "./structured.js";
import {
GraphQLBoolean,
GraphQLString,
@@ -132,7 +133,7 @@ export function querySchema(
throw new QueryCompileError("QUERY_TYPE", "Only scalar/optional scalar query fields are supported");
return queryScalars[type.name];
};
const comparison = (type: ValueType) => {
const scalarComparison = (type: ValueType) => {
const base = type.kind === "optional" ? type.value : type;
const value = scalar(base);
const key = String(value);
@@ -149,8 +150,21 @@ export function querySchema(
}
return result;
};
const structured = structuredQueryTypes(scalar, scalarComparison, direction);
const comparison = (type: ValueType) => structured.input(type) as GraphQLInputObjectType;
const relational = relationalSchema(
{ contracts, target, name, scalar, comparison, scalarTypes, direction, cursor: cursorScalar, pageInfo },
{
contracts,
target,
name,
scalar,
comparison,
valueOrder: (type) => structured.input(type, true),
scalarTypes,
direction,
cursor: cursorScalar,
pageInfo,
},
document,
);
relational.discover(root.interface);
@@ -162,7 +176,7 @@ export function querySchema(
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 (comparableValue(t, true)) fields[member.displayName] = { type: structured.input(t, true) };
}
if (Object.keys(fields).length === 0) return undefined;
result = new GraphQLInputObjectType({ name: `${name(id)}Order`, fields });
@@ -190,10 +204,7 @@ export function querySchema(
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),
};
fields[member.displayName] = { type: structured.output(member.valueType) };
} else {
const targetId = target(member),
node = object(targetId);
+76
View File
@@ -0,0 +1,76 @@
import { createHash } from "node:crypto";
import {
GraphQLObjectType,
GraphQLInputObjectType,
GraphQLList,
GraphQLNonNull,
type GraphQLNullableType,
type GraphQLOutputType,
type GraphQLInputType,
type GraphQLEnumType,
} from "graphql";
import type { ValueType } from "../capability-model/types.js";
import { QueryCompileError } from "./types.js";
export function comparableValue(type: ValueType, ordering = false): boolean {
if (type.kind === "optional") return comparableValue(type.value, ordering);
if (type.kind === "record") return Object.values(type.fields).some((t) => comparableValue(t, ordering));
return type.kind === "scalar" && (!ordering || type.name !== "bytes");
}
/** Structured values retain one field identity; their children are value projections. */
export function structuredQueryTypes(
scalar: (type: ValueType) => GraphQLInputType & GraphQLOutputType,
scalarComparison: (type: ValueType) => GraphQLInputObjectType,
direction: GraphQLEnumType,
) {
const objects = new Map<string, GraphQLObjectType>();
const inputs = new Map<string, GraphQLInputObjectType>();
const name = (role: string, type: ValueType) =>
`QxValue${role}_${createHash("sha256").update(JSON.stringify(type)).digest("hex").slice(0, 16)}`;
const output = (type: ValueType, depth = 0): GraphQLOutputType => {
if (depth > 128) throw new QueryCompileError("QUERY_TYPE", "Structured value exceeds type depth limit");
if (type.kind === "optional") return required(type.value, depth + 1);
return new GraphQLNonNull(required(type, depth));
};
const required = (type: ValueType, depth: number): GraphQLNullableType & GraphQLOutputType => {
if (type.kind === "optional") return required(type.value, depth + 1);
if (type.kind === "list") return new GraphQLList(output(type.value, depth + 1));
if (type.kind !== "record") return scalar(type) as GraphQLNullableType & GraphQLOutputType;
const key = name("Record", type);
let result = objects.get(key);
if (!result) {
if (!Object.keys(type.fields).length)
throw new QueryCompileError("QUERY_TYPE", "Queryable records must have at least one field");
result = new GraphQLObjectType({
name: key,
fields: () =>
Object.fromEntries(
Object.entries(type.fields).map(([key, value]) => [key, { type: output(value, depth + 1) }]),
),
});
objects.set(key, result);
}
return result;
};
const input = (type: ValueType, ordering = false): GraphQLInputType => {
if (type.kind === "optional") return input(type.value, ordering);
if (type.kind !== "record") return ordering ? direction : scalarComparison(type);
const key = name(ordering ? "Order" : "Compare", type);
let result = inputs.get(key);
if (!result) {
result = new GraphQLInputObjectType({
name: key,
fields: () =>
Object.fromEntries(
Object.entries(type.fields)
.filter(([, value]) => comparableValue(value, ordering))
.map(([key, value]) => [key, { type: input(value, ordering) }]),
),
});
inputs.set(key, result);
}
return result;
};
return { output, input };
}