Add explicit CRDT edit contracts and structured query values
This commit is contained in:
@@ -16,7 +16,7 @@ export type ValueTypeExpression =
|
||||
| Extract<ValueType, { kind: "builtin" | "scalar" | "message" }>
|
||||
| { kind: "parameter"; parameterId: string }
|
||||
| { kind: "record"; fields: Record<string, ValueTypeExpression> }
|
||||
| { kind: "list" | "optional"; value: ValueTypeExpression }
|
||||
| { kind: "list" | "optional" | "change"; value: ValueTypeExpression }
|
||||
| { kind: "object-ref"; expectation: ObjectTypeExpression }
|
||||
| { kind: "alias"; definitionId: string; arguments: TypeArgumentExpression[] };
|
||||
|
||||
@@ -184,10 +184,10 @@ export const isStorableType = (
|
||||
case "list":
|
||||
case "optional":
|
||||
return visit(value.value, depth + 1);
|
||||
// Records currently have RPC codecs, not ordinary-state persistence codecs.
|
||||
// A generic bound must not promise storage that the installed model rejects.
|
||||
case "record":
|
||||
case "change":
|
||||
return false;
|
||||
case "record":
|
||||
return Object.values(value.fields).every((field) => visit(field, depth + 1));
|
||||
}
|
||||
};
|
||||
return visit(type, 0);
|
||||
@@ -216,6 +216,7 @@ export const canonicalTypeArgument = (argument: ClosedTypeArgument): string => {
|
||||
return ["message", value.descriptorId];
|
||||
case "object-ref":
|
||||
return ["object-ref", target(value.expectation)];
|
||||
case "change":
|
||||
case "list":
|
||||
case "optional":
|
||||
return [value.kind, type(value.value, depth + 1)];
|
||||
@@ -323,6 +324,7 @@ export class TypeSubstitution {
|
||||
return { kind: "scalar", name: expression.name };
|
||||
case "message":
|
||||
return { kind: "message", descriptorId: expression.descriptorId };
|
||||
case "change":
|
||||
case "list":
|
||||
case "optional":
|
||||
return { kind: expression.kind, value: this.value(expression.value, `${path}.${expression.kind}`, depth + 1) };
|
||||
|
||||
@@ -67,7 +67,8 @@ export type ValueType =
|
||||
| { kind: "record"; fields: Record<string, ValueType> }
|
||||
| { kind: "object-ref"; expectation: ObjectExpectation }
|
||||
| { kind: "optional"; value: ValueType }
|
||||
| { kind: "list"; value: ValueType };
|
||||
| { kind: "list"; value: ValueType }
|
||||
| { kind: "change"; value: ValueType };
|
||||
|
||||
export const valueType = {
|
||||
unit: { kind: "builtin", name: "unit" } as const,
|
||||
@@ -214,7 +215,7 @@ export interface EdgeDefinition {
|
||||
|
||||
export type PersistentAttachment = StateSlotDefinition | EdgeDefinition;
|
||||
|
||||
export type StatePrimitive = "read" | "write" | "watch-start" | "watch-stop";
|
||||
export type StatePrimitive = "read" | "write" | "edit" | "watch-start" | "watch-stop";
|
||||
|
||||
export type EdgePrimitive = "resolve" | "connect" | "disconnect" | "watch-start" | "watch-stop";
|
||||
|
||||
|
||||
@@ -154,6 +154,8 @@ const typeLabel = (type: ValueType): string => {
|
||||
return type.expectation.kind === "atom"
|
||||
? `object:atom:${type.expectation.atomId}`
|
||||
: `object:interface:${type.expectation.interfaceRevisionId}`;
|
||||
case "change":
|
||||
return `change<${typeLabel(type.value)}>`;
|
||||
case "optional":
|
||||
return `optional<${typeLabel(type.value)}>`;
|
||||
case "list":
|
||||
@@ -189,6 +191,7 @@ export const valueTypesEqual = (left: ValueType, right: ValueType): boolean => {
|
||||
? left.expectation.atomId === (other as typeof left.expectation).atomId
|
||||
: left.expectation.interfaceRevisionId === (other as typeof left.expectation).interfaceRevisionId;
|
||||
}
|
||||
case "change":
|
||||
case "optional":
|
||||
case "list":
|
||||
return valueTypesEqual(left.value, (right as typeof left).value);
|
||||
@@ -239,6 +242,7 @@ const validateValueType = (
|
||||
case "message":
|
||||
requireText(issues, type.descriptorId, `${path}.descriptorId`, "Message descriptor identity");
|
||||
return;
|
||||
case "change":
|
||||
case "optional":
|
||||
case "list":
|
||||
validateValueType(issues, type.value, `${path}.value`, indexes);
|
||||
@@ -341,6 +345,8 @@ export const statePrimitiveSignature = (slot: StateSlotDefinition, primitive: St
|
||||
inputType: valueType.unit,
|
||||
outputType: slot.valueType,
|
||||
};
|
||||
case "edit":
|
||||
return { mode: "call", inputType: { kind: "change", value: slot.valueType }, outputType: valueType.unit };
|
||||
case "write":
|
||||
return {
|
||||
mode: "call",
|
||||
@@ -532,9 +538,19 @@ const collectIdentityIndexes = (
|
||||
if (member.kind === "relationship" && contract === "rpc-permitted")
|
||||
issue(issues, "invalid-query-contract", memberPath, "RPC query relationships are not supported");
|
||||
if (member.kind === "value") {
|
||||
const type = member.valueType.kind === "optional" ? member.valueType.value : member.valueType;
|
||||
if (type.kind !== "scalar")
|
||||
issue(issues, "invalid-query-contract", memberPath, "Queryable values must be scalar or optional scalar");
|
||||
const queryValue = (type: ValueType): boolean =>
|
||||
type.kind === "scalar" ||
|
||||
((type.kind === "optional" || type.kind === "list") && queryValue(type.value)) ||
|
||||
(type.kind === "record" &&
|
||||
Object.keys(type.fields).length > 0 &&
|
||||
Object.values(type.fields).every(queryValue));
|
||||
if (!queryValue(member.valueType))
|
||||
issue(
|
||||
issues,
|
||||
"invalid-query-contract",
|
||||
memberPath,
|
||||
"Queryable values require scalar leaves in optional, list, or nonempty record shapes",
|
||||
);
|
||||
}
|
||||
}
|
||||
requireText(issues, member.id, `${memberPath}.id`, "Member ID");
|
||||
@@ -837,7 +853,8 @@ const validateAttachments = (issues: CapabilityValidationIssue[], indexes: Valid
|
||||
validateValueType(issues, attachment.valueType, `${path}.valueType`, indexes);
|
||||
const containsRpcType = (type: ValueType): boolean =>
|
||||
type.kind === "object-ref" ||
|
||||
type.kind === "record" ||
|
||||
type.kind === "change" ||
|
||||
(type.kind === "record" && Object.values(type.fields).some(containsRpcType)) ||
|
||||
((type.kind === "optional" || type.kind === "list") && containsRpcType(type.value));
|
||||
if (
|
||||
containsRpcType(attachment.valueType) ||
|
||||
@@ -847,7 +864,7 @@ const validateAttachments = (issues: CapabilityValidationIssue[], indexes: Valid
|
||||
issues,
|
||||
"invalid-attachment",
|
||||
`${path}.valueType`,
|
||||
"Managed object references belong in graph relationships, not ordinary state; record types are RPC-only",
|
||||
"Managed object references belong in graph relationships; captured changes are transport-only",
|
||||
);
|
||||
}
|
||||
if (attachment.storagePolicy.kind === "crdt-document") {
|
||||
@@ -1287,6 +1304,12 @@ const validateBoundDependencies = (
|
||||
: `State ${stateBinding.slotId} is not attached to receiver atom ${params.atomId}`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
requirement.primitives.includes("edit") &&
|
||||
(attachment.attachment.storagePolicy.kind !== "crdt-document" ||
|
||||
!valueTypesEqual(attachment.attachment.valueType, attachment.attachment.storagePolicy.updateType))
|
||||
)
|
||||
issue(issues, "invalid-dependency-binding", path, "Edit ports require a matching CRDT slot");
|
||||
if (!valueTypesEqual(requirement.valueType, attachment.attachment.valueType)) {
|
||||
issue(
|
||||
issues,
|
||||
@@ -1579,6 +1602,17 @@ const validateConformances = (
|
||||
}
|
||||
} else binding = entry.binding;
|
||||
const member = operationEntry.member;
|
||||
if (
|
||||
member.kind === "value" &&
|
||||
operation.displayName === "edit" &&
|
||||
(binding.kind !== "state" || binding.primitive !== "edit")
|
||||
)
|
||||
issue(
|
||||
issues,
|
||||
"invalid-state-binding",
|
||||
bindingPath,
|
||||
"CRDT edit operations must resolve to a native state edit capability",
|
||||
);
|
||||
if (
|
||||
member.kind !== "operation" &&
|
||||
member.queryRead &&
|
||||
@@ -1652,6 +1686,17 @@ const validateConformances = (
|
||||
"State primitives may only implement value members",
|
||||
);
|
||||
}
|
||||
if (
|
||||
binding.primitive === "edit" &&
|
||||
(attachment.attachment.storagePolicy.kind !== "crdt-document" ||
|
||||
!valueTypesEqual(attachment.attachment.valueType, attachment.attachment.storagePolicy.updateType))
|
||||
)
|
||||
issue(
|
||||
issues,
|
||||
"invalid-state-binding",
|
||||
bindingPath,
|
||||
"Edit requires a CRDT slot with a matching materialized profile type",
|
||||
);
|
||||
const expected = statePrimitiveSignature(attachment.attachment, binding.primitive);
|
||||
if (!signaturesMatch(operation, expected)) {
|
||||
issue(
|
||||
|
||||
Reference in New Issue
Block a user