428 lines
16 KiB
TypeScript
428 lines
16 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import {
|
|
capabilityId,
|
|
type AtomId,
|
|
type InterfaceMember,
|
|
type InterfaceOperation,
|
|
type InterfaceRevision,
|
|
type InterfaceRevisionId,
|
|
type ObjectExpectation,
|
|
type SourceRevision,
|
|
type ValueType,
|
|
} from "./types.js";
|
|
|
|
/** Authoring-only expressions. Installed ValueType deliberately has no variable case. */
|
|
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: "object-ref"; expectation: ObjectTypeExpression }
|
|
| { kind: "alias"; definitionId: string; arguments: TypeArgumentExpression[] };
|
|
|
|
export type ObjectTypeExpression =
|
|
| { kind: "atom"; atomId: AtomId }
|
|
| { kind: "interface"; interfaceRevisionId: InterfaceRevisionId }
|
|
| { kind: "parameter"; parameterId: string }
|
|
| { kind: "self" }
|
|
| { kind: "application"; application: InterfaceApplicationExpression };
|
|
|
|
export type TypeArgumentExpression =
|
|
| { kind: "value"; type: ValueTypeExpression }
|
|
| { kind: "object"; target: ObjectTypeExpression };
|
|
|
|
export type ClosedTypeArgument = { kind: "value"; type: ValueType } | { kind: "object"; target: ObjectExpectation };
|
|
|
|
export interface InterfaceApplicationExpression {
|
|
definitionId: InterfaceRevisionId;
|
|
arguments: TypeArgumentExpression[];
|
|
}
|
|
|
|
export type TypeParameter =
|
|
| { id: string; name: string; kind: "value"; storable?: boolean }
|
|
| { id: string; name: string; kind: "object"; implements: InterfaceApplicationExpression[] };
|
|
|
|
export interface ValueAliasDefinition {
|
|
id: string;
|
|
parameters: TypeParameter[];
|
|
body: ValueTypeExpression;
|
|
}
|
|
|
|
export interface GenericInterfaceTemplate {
|
|
parameters: TypeParameter[];
|
|
members: InterfaceMember<ValueTypeExpression, ObjectTypeExpression>[];
|
|
requires: InterfaceApplicationExpression[];
|
|
usesSelf: boolean;
|
|
aliases?: ValueAliasDefinition[];
|
|
}
|
|
|
|
/** Instantiation produces the existing closed runtime IR, with explicit provenance. */
|
|
export const instantiateInterface = (
|
|
definition: InterfaceRevision,
|
|
arguments_: readonly ClosedTypeArgument[],
|
|
environment: GenericTypeEnvironment,
|
|
): InterfaceRevision => {
|
|
const template = definition.template;
|
|
if (!template) {
|
|
if (arguments_.length) fail("type-arity", definition.displayName, "Non-generic interface takes no type arguments");
|
|
return definition;
|
|
}
|
|
const lexicalEnvironment = {
|
|
...environment,
|
|
aliases: new Map((template.aliases ?? []).map((alias) => [alias.id, alias])),
|
|
};
|
|
const argumentsMap = bindTypeParameters(template.parameters, arguments_, lexicalEnvironment, definition.displayName);
|
|
if (template.usesSelf && !environment.self)
|
|
fail("unbound-self", definition.displayName, "Self requires an implementing atom");
|
|
const substitution = new TypeSubstitution({ ...lexicalEnvironment, arguments: argumentsMap });
|
|
const operation = (entry: InterfaceOperation<ValueTypeExpression>): InterfaceOperation => ({
|
|
...entry,
|
|
inputType: substitution.value(entry.inputType, `${definition.displayName}.${entry.displayName}.input`),
|
|
outputType: substitution.value(entry.outputType, `${definition.displayName}.${entry.displayName}.output`),
|
|
eventType: entry.eventType
|
|
? substitution.value(entry.eventType, `${definition.displayName}.${entry.displayName}.event`)
|
|
: undefined,
|
|
});
|
|
const members: InterfaceMember[] = template.members.map((member) => {
|
|
const common = { id: member.id, displayName: member.displayName, operations: member.operations.map(operation) };
|
|
switch (member.kind) {
|
|
case "value":
|
|
return {
|
|
...common,
|
|
kind: "value",
|
|
...(member.queryRead ? { queryRead: { ...member.queryRead } } : {}),
|
|
valueType: substitution.value(member.valueType, member.displayName),
|
|
};
|
|
case "relationship":
|
|
return {
|
|
...common,
|
|
kind: "relationship",
|
|
...(member.queryRead ? { queryRead: { ...member.queryRead } } : {}),
|
|
target: substitution.object(member.target, member.displayName),
|
|
cardinality: member.cardinality,
|
|
ordered: member.ordered,
|
|
...(member.keyType ? { keyType: member.keyType } : {}),
|
|
};
|
|
case "operation":
|
|
return {
|
|
...common,
|
|
kind: "operation",
|
|
inputType: substitution.value(member.inputType, member.displayName),
|
|
outputType: substitution.value(member.outputType, member.displayName),
|
|
};
|
|
}
|
|
});
|
|
const application: AppliedInterfaceIdentity = {
|
|
definitionId: definition.revisionId,
|
|
source: definition.source,
|
|
arguments: structuredClone([...arguments_]),
|
|
...(template.usesSelf ? { self: environment.self } : {}),
|
|
};
|
|
return {
|
|
interfaceId: definition.interfaceId,
|
|
revisionId: appliedInterfaceId(application),
|
|
displayName: definition.displayName,
|
|
source: definition.source,
|
|
members,
|
|
application,
|
|
requiredInterfaces: template.requires.map((required) => substitution.application(required)),
|
|
};
|
|
};
|
|
|
|
export interface GenericTypeEnvironment {
|
|
arguments: ReadonlyMap<string, ClosedTypeArgument>;
|
|
self?: AtomId;
|
|
aliases?: ReadonlyMap<string, ValueAliasDefinition>;
|
|
/** Resolves only checked declarations, never a caller-supplied runtime type string. */
|
|
applyInterface: (definitionId: InterfaceRevisionId, arguments_: readonly ClosedTypeArgument[]) => InterfaceRevisionId;
|
|
/** Proof in the candidate, not an authorization grant. */
|
|
implementsInterface: (target: ObjectExpectation, required: InterfaceRevisionId) => boolean;
|
|
/** External codecs must explicitly declare reference-free persistence support. */
|
|
storableMessage?: (descriptorId: string) => boolean;
|
|
}
|
|
|
|
export class GenericTypeError extends Error {
|
|
constructor(
|
|
readonly code: string,
|
|
readonly path: string,
|
|
message: string,
|
|
) {
|
|
super(`${path}: ${message}`);
|
|
this.name = "GenericTypeError";
|
|
}
|
|
}
|
|
|
|
const fail = (code: string, path: string, message: string): never => {
|
|
throw new GenericTypeError(code, path, message);
|
|
};
|
|
|
|
/** One budget across nested aliases/substitutions, including concrete arguments. */
|
|
class Budget {
|
|
private remaining = 10000;
|
|
enter(path: string, depth: number) {
|
|
if (depth > 128 || --this.remaining < 0)
|
|
fail("type-complexity-limit", path, "Type exceeds the depth or expansion budget");
|
|
}
|
|
}
|
|
|
|
export const isStorableType = (
|
|
type: ValueType,
|
|
storableMessage: (descriptorId: string) => boolean = () => false,
|
|
): boolean => {
|
|
const budget = new Budget();
|
|
const visit = (value: ValueType, depth: number): boolean => {
|
|
budget.enter("storable", depth);
|
|
switch (value.kind) {
|
|
case "scalar":
|
|
return true;
|
|
case "builtin":
|
|
return value.name === "unit";
|
|
case "message":
|
|
return storableMessage(value.descriptorId);
|
|
case "object-ref":
|
|
return false;
|
|
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":
|
|
return false;
|
|
}
|
|
};
|
|
return visit(type, 0);
|
|
};
|
|
|
|
/** Canonical closed-type encoding, independent of record insertion order. */
|
|
export const canonicalTypeArgument = (argument: ClosedTypeArgument): string => {
|
|
const budget = new Budget();
|
|
const target = (value: ObjectExpectation): unknown => {
|
|
switch (value.kind) {
|
|
case "atom":
|
|
return ["atom", value.atomId];
|
|
case "interface":
|
|
return ["interface", value.interfaceRevisionId];
|
|
default:
|
|
return fail("unresolved-type", "argument", "Expected a closed object target");
|
|
}
|
|
};
|
|
const type = (value: ValueType, depth: number): unknown => {
|
|
budget.enter("argument", depth);
|
|
switch (value.kind) {
|
|
case "builtin":
|
|
case "scalar":
|
|
return [value.kind, value.name];
|
|
case "message":
|
|
return ["message", value.descriptorId];
|
|
case "object-ref":
|
|
return ["object-ref", target(value.expectation)];
|
|
case "list":
|
|
case "optional":
|
|
return [value.kind, type(value.value, depth + 1)];
|
|
case "record":
|
|
return [
|
|
"record",
|
|
Object.keys(value.fields)
|
|
.sort()
|
|
.map((key) => [key, type(value.fields[key], depth + 1)]),
|
|
];
|
|
default:
|
|
return fail("unresolved-type", "argument", "Expected a closed value type");
|
|
}
|
|
};
|
|
switch (argument.kind) {
|
|
case "value":
|
|
return JSON.stringify(["value", type(argument.type, 0)]);
|
|
case "object":
|
|
return JSON.stringify(["object", target(argument.target)]);
|
|
default:
|
|
return fail("invalid-kind", "argument", "Expected a value or object type argument");
|
|
}
|
|
};
|
|
|
|
export interface AppliedInterfaceIdentity {
|
|
definitionId: InterfaceRevisionId;
|
|
source: SourceRevision;
|
|
arguments: ClosedTypeArgument[];
|
|
/** Only include Self when it is actually part of the closed contract. */
|
|
self?: AtomId;
|
|
}
|
|
|
|
export const appliedInterfaceId = (application: AppliedInterfaceIdentity): InterfaceRevisionId => {
|
|
const encoding = JSON.stringify([
|
|
"quixos-applied-interface-v1",
|
|
application.definitionId,
|
|
application.source.repository,
|
|
application.source.commit.toLowerCase(),
|
|
application.arguments.map(canonicalTypeArgument),
|
|
application.self ?? null,
|
|
]);
|
|
return capabilityId.interfaceRevision(
|
|
`interface-application:sha256:${createHash("sha256").update(encoding).digest("hex")}`,
|
|
);
|
|
};
|
|
|
|
export class TypeSubstitution {
|
|
private readonly budget = new Budget();
|
|
private readonly aliases: string[] = [];
|
|
|
|
constructor(private environment: GenericTypeEnvironment) {}
|
|
|
|
argument(expression: TypeArgumentExpression, path = "argument", depth = 0): ClosedTypeArgument {
|
|
this.budget.enter(path, depth);
|
|
switch (expression.kind) {
|
|
case "value":
|
|
return { kind: "value", type: this.value(expression.type, path, depth + 1) };
|
|
case "object":
|
|
return { kind: "object", target: this.object(expression.target, path, depth + 1) };
|
|
default:
|
|
return fail("invalid-kind", path, "Expected a value or object type argument");
|
|
}
|
|
}
|
|
|
|
application(expression: InterfaceApplicationExpression, path = "interface", depth = 0): InterfaceRevisionId {
|
|
this.budget.enter(path, depth);
|
|
return this.environment.applyInterface(
|
|
expression.definitionId,
|
|
expression.arguments.map((argument, index) => this.argument(argument, `${path}.arguments[${index}]`, depth + 1)),
|
|
);
|
|
}
|
|
|
|
object(expression: ObjectTypeExpression, path = "target", depth = 0): ObjectExpectation {
|
|
this.budget.enter(path, depth);
|
|
switch (expression.kind) {
|
|
case "atom":
|
|
return { kind: "atom", atomId: expression.atomId };
|
|
case "interface":
|
|
return { kind: "interface", interfaceRevisionId: expression.interfaceRevisionId };
|
|
case "self":
|
|
return this.environment.self
|
|
? { kind: "atom", atomId: this.environment.self }
|
|
: fail("unbound-self", path, "Self requires an implementing atom");
|
|
case "parameter": {
|
|
const argument = this.environment.arguments.get(expression.parameterId);
|
|
if (!argument) return fail("unbound-parameter", path, `Unbound parameter ${expression.parameterId}`);
|
|
if (argument.kind !== "object")
|
|
return fail("parameter-kind", path, `Parameter ${expression.parameterId} is a value, not an object target`);
|
|
canonicalTypeArgument(argument);
|
|
return structuredClone(argument.target);
|
|
}
|
|
case "application":
|
|
return { kind: "interface", interfaceRevisionId: this.application(expression.application, path, depth + 1) };
|
|
default:
|
|
return fail("invalid-type", path, "Unknown object type expression");
|
|
}
|
|
}
|
|
|
|
value(expression: ValueTypeExpression, path = "type", depth = 0): ValueType {
|
|
this.budget.enter(path, depth);
|
|
switch (expression.kind) {
|
|
case "builtin":
|
|
return { kind: "builtin", name: expression.name };
|
|
case "scalar":
|
|
return { kind: "scalar", name: expression.name };
|
|
case "message":
|
|
return { kind: "message", descriptorId: expression.descriptorId };
|
|
case "list":
|
|
case "optional":
|
|
return { kind: expression.kind, value: this.value(expression.value, `${path}.${expression.kind}`, depth + 1) };
|
|
case "record":
|
|
return {
|
|
kind: "record",
|
|
fields: Object.fromEntries(
|
|
Object.entries(expression.fields).map(([name, field]) => [
|
|
name,
|
|
this.value(field, `${path}.${name}`, depth + 1),
|
|
]),
|
|
),
|
|
};
|
|
case "object-ref":
|
|
return { kind: "object-ref", expectation: this.object(expression.expectation, `${path}.ref`, depth + 1) };
|
|
case "parameter": {
|
|
const argument = this.environment.arguments.get(expression.parameterId);
|
|
if (!argument) return fail("unbound-parameter", path, `Unbound parameter ${expression.parameterId}`);
|
|
if (argument.kind !== "value")
|
|
return fail("parameter-kind", path, `Parameter ${expression.parameterId} is an object target; use ref<T>`);
|
|
canonicalTypeArgument(argument);
|
|
return this.value(argument.type, path, depth + 1);
|
|
}
|
|
case "alias": {
|
|
const alias = this.environment.aliases?.get(expression.definitionId);
|
|
if (!alias) return fail("unknown-alias", path, `Unknown type alias ${expression.definitionId}`);
|
|
if (this.aliases.includes(alias.id))
|
|
return fail("recursive-alias", path, `Recursive value alias: ${[...this.aliases, alias.id].join(" -> ")}`);
|
|
const arguments_ = expression.arguments.map((argument, index) =>
|
|
this.argument(argument, `${path}.arguments[${index}]`, depth + 1),
|
|
);
|
|
const bindings = bindTypeParameters(alias.parameters, arguments_, this.environment, path);
|
|
this.aliases.push(alias.id);
|
|
try {
|
|
// Reuse this expansion budget/stack; lexical parameter maps are restored.
|
|
const previous = this.environment;
|
|
this.environment = { ...previous, arguments: bindings };
|
|
try {
|
|
return this.value(alias.body, `${path}.${alias.id}`, depth + 1);
|
|
} finally {
|
|
this.environment = previous;
|
|
}
|
|
} finally {
|
|
this.aliases.pop();
|
|
}
|
|
}
|
|
default:
|
|
return fail("invalid-type", path, "Unknown value type expression");
|
|
}
|
|
}
|
|
}
|
|
|
|
export const bindTypeParameters = (
|
|
parameters: readonly TypeParameter[],
|
|
arguments_: readonly ClosedTypeArgument[],
|
|
environment: GenericTypeEnvironment,
|
|
path = "parameters",
|
|
): ReadonlyMap<string, ClosedTypeArgument> => {
|
|
if (parameters.length !== arguments_.length)
|
|
fail("type-arity", path, `Expected ${parameters.length} type arguments, received ${arguments_.length}`);
|
|
const bindings = new Map<string, ClosedTypeArgument>();
|
|
const names = new Set<string>();
|
|
for (const [index, parameter] of parameters.entries()) {
|
|
if (
|
|
!parameter.id ||
|
|
!parameter.name ||
|
|
parameter.name === "Self" ||
|
|
bindings.has(parameter.id) ||
|
|
names.has(parameter.name)
|
|
)
|
|
fail("duplicate-parameter", path, `Invalid or duplicate parameter ${parameter.name}`);
|
|
names.add(parameter.name);
|
|
const argument = arguments_[index];
|
|
if (parameter.kind !== argument.kind)
|
|
fail("parameter-kind", `${path}.${parameter.name}`, `Expected ${parameter.kind}, received ${argument.kind}`);
|
|
canonicalTypeArgument(argument);
|
|
bindings.set(parameter.id, structuredClone(argument));
|
|
}
|
|
const substitution = new TypeSubstitution({ ...environment, arguments: bindings });
|
|
for (const parameter of parameters) {
|
|
const argument = bindings.get(parameter.id)!;
|
|
if (
|
|
parameter.kind === "value" &&
|
|
argument.kind === "value" &&
|
|
parameter.storable &&
|
|
!isStorableType(argument.type, environment.storableMessage)
|
|
)
|
|
fail(
|
|
"non-storable-argument",
|
|
`${path}.${parameter.name}`,
|
|
"State values cannot contain managed references or unsupported transport values",
|
|
);
|
|
if (parameter.kind === "object" && argument.kind === "object") {
|
|
for (const bound of parameter.implements) {
|
|
const required = substitution.application(bound, `${path}.${parameter.name}.implements`);
|
|
if (!environment.implementsInterface(argument.target, required))
|
|
fail("unsatisfied-bound", `${path}.${parameter.name}`, `Object target does not implement ${required}`);
|
|
}
|
|
}
|
|
}
|
|
return bindings;
|
|
};
|