Implement capability generics, checked package specializations and CRUD scaffolding
Add kinded parameters, capability bounds, Self, aliases and closed application identities. Check generic implementations universally and build candidate-specific codecs and descriptors from immutable schemas. Preserve lexical aliases and exact dispatch identities in package and host bindings. Add an imperative CRUD+index domain scaffold with explicit soft-deletion semantics, source/codegen regression coverage, installed CLI tests and an authoring guide. Existing Web Studio opaque props and class-level create-menu migration are separate from the implemented language core.
This commit is contained in:
@@ -184,6 +184,9 @@ export const runtimeContracts = (workspace: WorkspaceRevision): RuntimeContract[
|
||||
materializations: sorted(conformance.relationshipMaterializations, (entry) => entry.memberId),
|
||||
});
|
||||
parent.dependencies.add(`interface:${conformance.interfaceRevisionId}`);
|
||||
const contract = workspace.interfaceImports.find((entry) => entry.revisionId === conformance.interfaceRevisionId);
|
||||
for (const required of contract?.requiredInterfaces ?? [])
|
||||
parent.dependencies.add(conformanceKey(conformance.atomId, required));
|
||||
for (const attachment of conformance.privateAttachments) parent.dependencies.add(`attachment:${attachment.id}`);
|
||||
for (const operation of conformance.operationBindings)
|
||||
binding(parent, operation.binding, conformance.atomId, {
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
capabilityId,
|
||||
type PackageExport,
|
||||
type PackageExportId,
|
||||
type DependencyPort,
|
||||
type PackageRevision,
|
||||
} from "./types.js";
|
||||
import {
|
||||
TypeSubstitution,
|
||||
GenericTypeError,
|
||||
bindTypeParameters,
|
||||
canonicalTypeArgument,
|
||||
type TypeParameter,
|
||||
type ValueTypeExpression,
|
||||
type ObjectTypeExpression,
|
||||
type InterfaceApplicationExpression,
|
||||
type GenericTypeEnvironment,
|
||||
type ClosedTypeArgument,
|
||||
type ValueAliasDefinition,
|
||||
} from "./generics.js";
|
||||
|
||||
export type GenericDependencyPort = Omit<DependencyPort, "requirement"> & {
|
||||
requirement:
|
||||
| {
|
||||
kind: "state";
|
||||
valueType: ValueTypeExpression;
|
||||
primitives: Extract<DependencyPort["requirement"], { kind: "state" }>["primitives"];
|
||||
}
|
||||
| {
|
||||
kind: "edge";
|
||||
target: ObjectTypeExpression;
|
||||
cardinality: Extract<DependencyPort["requirement"], { kind: "edge" }>["cardinality"];
|
||||
primitives: Extract<DependencyPort["requirement"], { kind: "edge" }>["primitives"];
|
||||
}
|
||||
| { kind: "interface"; application: InterfaceApplicationExpression }
|
||||
| { kind: "constructor"; target: ObjectTypeExpression; inputType: ValueTypeExpression };
|
||||
};
|
||||
|
||||
export interface GenericPackageExport {
|
||||
id: PackageExportId;
|
||||
displayName: string;
|
||||
parameters: TypeParameter[];
|
||||
kind: "operation" | "function";
|
||||
inputType: ValueTypeExpression;
|
||||
outputType: ValueTypeExpression;
|
||||
eventType?: ValueTypeExpression;
|
||||
mode?: Extract<PackageExport, { kind: "operation" }>["mode"];
|
||||
receiverRequirement:
|
||||
| { kind: "any-object" }
|
||||
| { kind: "target"; target: ObjectTypeExpression }
|
||||
| { kind: "interfaces"; interfaces: InterfaceApplicationExpression[] };
|
||||
dependencyPorts: GenericDependencyPort[];
|
||||
aliases: ValueAliasDefinition[];
|
||||
}
|
||||
|
||||
/** A closed manifest is a Nix build input, never a mutable source checkout. */
|
||||
export const specializePackageExport = (
|
||||
pkg: PackageRevision,
|
||||
definition: GenericPackageExport,
|
||||
arguments_: ClosedTypeArgument[],
|
||||
environment: GenericTypeEnvironment,
|
||||
): PackageExport => {
|
||||
const lexical = { ...environment, aliases: new Map(definition.aliases.map((alias) => [alias.id, alias])) };
|
||||
const bindings = bindTypeParameters(definition.parameters, arguments_, lexical, definition.displayName);
|
||||
const substitution = new TypeSubstitution({ ...lexical, arguments: bindings });
|
||||
const digest = createHash("sha256")
|
||||
.update(
|
||||
JSON.stringify([
|
||||
"quixos-package-specialization-v1",
|
||||
pkg.revisionId,
|
||||
[pkg.source.repository, pkg.source.commit],
|
||||
definition.id,
|
||||
arguments_.map(canonicalTypeArgument),
|
||||
environment.self ?? null,
|
||||
]),
|
||||
)
|
||||
.digest("hex");
|
||||
const ports: DependencyPort[] = definition.dependencyPorts.map((port) => {
|
||||
const requirement = port.requirement;
|
||||
switch (requirement.kind) {
|
||||
case "state":
|
||||
return { ...port, requirement: { ...requirement, valueType: substitution.value(requirement.valueType) } };
|
||||
case "edge":
|
||||
return { ...port, requirement: { ...requirement, target: substitution.object(requirement.target) } };
|
||||
case "interface":
|
||||
return {
|
||||
...port,
|
||||
requirement: { kind: "interface", interfaceRevisionId: substitution.application(requirement.application) },
|
||||
};
|
||||
case "constructor": {
|
||||
const target = substitution.object(requirement.target);
|
||||
if (target.kind !== "atom")
|
||||
throw new GenericTypeError(
|
||||
"constructor-target",
|
||||
port.displayName,
|
||||
"Constructor ports require a concrete atom argument, not an interface view",
|
||||
);
|
||||
return {
|
||||
...port,
|
||||
requirement: {
|
||||
kind: "constructor",
|
||||
atomId: target.atomId,
|
||||
inputType: substitution.value(requirement.inputType),
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
const common = {
|
||||
id: capabilityId.packageExport(`export-application:sha256:${digest}`),
|
||||
displayName: `${definition.displayName}$${digest}`,
|
||||
inputType: substitution.value(definition.inputType),
|
||||
outputType: substitution.value(definition.outputType),
|
||||
dependencyPorts: ports,
|
||||
application: {
|
||||
exportId: definition.id,
|
||||
arguments: structuredClone(arguments_),
|
||||
...(environment.self ? { self: environment.self } : {}),
|
||||
},
|
||||
};
|
||||
if (definition.kind === "function") return { ...common, kind: "function" };
|
||||
const receiver = definition.receiverRequirement;
|
||||
const target = receiver.kind === "target" ? substitution.object(receiver.target) : undefined;
|
||||
return {
|
||||
...common,
|
||||
kind: "operation",
|
||||
mode: definition.mode!,
|
||||
...(definition.eventType ? { eventType: substitution.value(definition.eventType) } : {}),
|
||||
receiverRequirement: target
|
||||
? target.kind === "atom"
|
||||
? { kind: "exact-atom", atomId: target.atomId }
|
||||
: { kind: "all-interfaces", interfaceRevisionIds: [target.interfaceRevisionId] }
|
||||
: receiver.kind === "interfaces"
|
||||
? {
|
||||
kind: "all-interfaces",
|
||||
interfaceRevisionIds: receiver.interfaces.map((value) => substitution.application(value)),
|
||||
}
|
||||
: { kind: "any-object" },
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,420 @@
|
||||
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", valueType: substitution.value(member.valueType, member.displayName) };
|
||||
case "relationship":
|
||||
return {
|
||||
...common,
|
||||
kind: "relationship",
|
||||
target: substitution.object(member.target, member.displayName),
|
||||
cardinality: member.cardinality,
|
||||
ordered: member.ordered,
|
||||
};
|
||||
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;
|
||||
};
|
||||
@@ -2,3 +2,5 @@ export * from "./types.js";
|
||||
export * from "./validation.js";
|
||||
export * from "./evolution.js";
|
||||
export * from "./migrations.js";
|
||||
export * from "./generics.js";
|
||||
export * from "./generic-packages.js";
|
||||
|
||||
@@ -104,25 +104,25 @@ export interface AtomDefinition {
|
||||
|
||||
export type InterfaceOperationMode = "call" | "watch-start" | "watch-stop" | "subscribe" | "unsubscribe";
|
||||
|
||||
export interface InterfaceOperation {
|
||||
export interface InterfaceOperation<Type = ValueType> {
|
||||
id: OperationId;
|
||||
displayName: string;
|
||||
inputType: ValueType;
|
||||
outputType: ValueType;
|
||||
inputType: Type;
|
||||
outputType: Type;
|
||||
mode: InterfaceOperationMode;
|
||||
/** Required for watch-start/subscribe and absent for other modes. */
|
||||
eventType?: ValueType;
|
||||
eventType?: Type;
|
||||
}
|
||||
|
||||
interface InterfaceMemberBase {
|
||||
interface InterfaceMemberBase<Type = ValueType> {
|
||||
id: MemberId;
|
||||
displayName: string;
|
||||
operations: InterfaceOperation[];
|
||||
operations: InterfaceOperation<Type>[];
|
||||
}
|
||||
|
||||
export interface ValueInterfaceMember extends InterfaceMemberBase {
|
||||
export interface ValueInterfaceMember<Type = ValueType> extends InterfaceMemberBase<Type> {
|
||||
kind: "value";
|
||||
valueType: ValueType;
|
||||
valueType: Type;
|
||||
}
|
||||
|
||||
export type EdgeCardinality = "optional-one" | "exactly-one" | "many" | "many-unique";
|
||||
@@ -134,21 +134,27 @@ export type EdgeEndpointConstraint =
|
||||
interfaceRevisionId: InterfaceRevisionId;
|
||||
};
|
||||
|
||||
export interface RelationshipInterfaceMember extends InterfaceMemberBase {
|
||||
export interface RelationshipInterfaceMember<
|
||||
Type = ValueType,
|
||||
Target = EdgeEndpointConstraint,
|
||||
> extends InterfaceMemberBase<Type> {
|
||||
kind: "relationship";
|
||||
target: EdgeEndpointConstraint;
|
||||
target: Target;
|
||||
cardinality: EdgeCardinality;
|
||||
ordered: boolean;
|
||||
}
|
||||
|
||||
/** A named callable capability that is not value or relationship sugar. */
|
||||
export interface OperationInterfaceMember extends InterfaceMemberBase {
|
||||
export interface OperationInterfaceMember<Type = ValueType> extends InterfaceMemberBase<Type> {
|
||||
kind: "operation";
|
||||
inputType: ValueType;
|
||||
outputType: ValueType;
|
||||
inputType: Type;
|
||||
outputType: Type;
|
||||
}
|
||||
|
||||
export type InterfaceMember = ValueInterfaceMember | RelationshipInterfaceMember | OperationInterfaceMember;
|
||||
export type InterfaceMember<Type = ValueType, Target = EdgeEndpointConstraint> =
|
||||
| ValueInterfaceMember<Type>
|
||||
| RelationshipInterfaceMember<Type, Target>
|
||||
| OperationInterfaceMember<Type>;
|
||||
|
||||
export interface InterfaceRevision {
|
||||
interfaceId: InterfaceId;
|
||||
@@ -156,6 +162,12 @@ export interface InterfaceRevision {
|
||||
displayName: string;
|
||||
source: SourceRevision;
|
||||
members: InterfaceMember[];
|
||||
/** Authored generic definition; never an executable contract by itself. */
|
||||
template?: import("./generics.js").GenericInterfaceTemplate;
|
||||
/** Immutable provenance of a closed generic application. */
|
||||
application?: import("./generics.js").AppliedInterfaceIdentity;
|
||||
requiredInterfaces?: InterfaceRevisionId[];
|
||||
argumentRequirements?: { target: ObjectExpectation; required: InterfaceRevisionId }[];
|
||||
}
|
||||
|
||||
export type StoragePolicy = { kind: "optimistic-register" } | { kind: "crdt-document"; updateType: ValueType };
|
||||
@@ -233,6 +245,8 @@ interface PackageExportBase {
|
||||
inputType: ValueType;
|
||||
outputType: ValueType;
|
||||
dependencyPorts: DependencyPort[];
|
||||
/** Closed adapter served by the same immutable package build. */
|
||||
application?: { exportId: PackageExportId; arguments: import("./generics.js").ClosedTypeArgument[]; self?: AtomId };
|
||||
}
|
||||
|
||||
export interface PackageOperationExport extends PackageExportBase {
|
||||
@@ -254,6 +268,8 @@ export interface PackageConstructorExport extends PackageExportBase {
|
||||
export type PackageExport = PackageOperationExport | PackageFunctionExport | PackageConstructorExport;
|
||||
|
||||
export interface PackageRevision {
|
||||
genericExports?: import("./generic-packages.js").GenericPackageExport[];
|
||||
argumentRequirements?: { target: ObjectExpectation; required: InterfaceRevisionId }[];
|
||||
migrationCatalog?: import("./migrations.js").MigrationCatalog;
|
||||
packageId: PackageId;
|
||||
revisionId: PackageRevisionId;
|
||||
|
||||
@@ -34,6 +34,9 @@ import type {
|
||||
WorkspaceRevision,
|
||||
} from "./types.js";
|
||||
import { valueType } from "./types.js";
|
||||
import { appliedInterfaceId, GenericTypeError } from "./generics.js";
|
||||
import { specializePackageExport } from "./generic-packages.js";
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
|
||||
export type CapabilityValidationIssueCode =
|
||||
| "invalid-semantic-major"
|
||||
@@ -217,7 +220,12 @@ const validateValueType = (
|
||||
}
|
||||
return;
|
||||
case "builtin":
|
||||
if (!["unit", "watch-handle"].includes(type.name))
|
||||
issue(issues, "invalid-value-type", path, "Unknown builtin value type");
|
||||
return;
|
||||
case "scalar":
|
||||
if (!["bool", "bytes", "double", "int32", "int64", "string", "uint32", "uint64"].includes(type.name))
|
||||
issue(issues, "invalid-value-type", path, "Unknown scalar value type");
|
||||
return;
|
||||
case "message":
|
||||
requireText(issues, type.descriptorId, `${path}.descriptorId`, "Message descriptor identity");
|
||||
@@ -244,6 +252,14 @@ const validateValueType = (
|
||||
`Unknown interface revision ${type.expectation.interfaceRevisionId}`,
|
||||
);
|
||||
}
|
||||
return;
|
||||
default:
|
||||
issue(
|
||||
issues,
|
||||
"invalid-value-type",
|
||||
path,
|
||||
"Installed contracts require closed value types; unresolved authoring expressions are not executable",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -458,9 +474,29 @@ const collectIdentityIndexes = (
|
||||
for (const [interfaceIndex, revision] of workspace.interfaceImports.entries()) {
|
||||
const path = `interfaceImports[${interfaceIndex}]`;
|
||||
requireText(issues, revision.interfaceId, `${path}.interfaceId`, "Interface ID");
|
||||
if (revision.template)
|
||||
issue(issues, "invalid-value-type", path, "Unapplied generic interface cannot enter an installed workspace");
|
||||
requireText(issues, revision.revisionId, `${path}.revisionId`, "Interface revision ID");
|
||||
requireText(issues, revision.displayName, `${path}.displayName`, "Interface name");
|
||||
validateSource(issues, revision.source, `${path}.source`);
|
||||
if (revision.application) {
|
||||
try {
|
||||
if (
|
||||
appliedInterfaceId(revision.application) !== revision.revisionId ||
|
||||
revision.application.source.repository !== revision.source.repository ||
|
||||
revision.application.source.commit !== revision.source.commit
|
||||
)
|
||||
issue(
|
||||
issues,
|
||||
"invalid-value-type",
|
||||
path,
|
||||
"Applied interface identity does not match its exact provenance and arguments",
|
||||
);
|
||||
} catch (error) {
|
||||
if (!(error instanceof GenericTypeError)) throw error;
|
||||
issue(issues, "invalid-value-type", path, error.message);
|
||||
}
|
||||
}
|
||||
const memberIds = new Set<string>();
|
||||
const operations = new Map<OperationId, InterfaceOperationEntry>();
|
||||
for (const [memberIndex, member] of revision.members.entries()) {
|
||||
@@ -516,6 +552,35 @@ const collectIdentityIndexes = (
|
||||
const exports = new Map<string, PackageExport>();
|
||||
for (const [exportIndex, entry] of revision.exports.entries()) {
|
||||
const exportPath = `${path}.exports[${exportIndex}]`;
|
||||
if (entry.application) {
|
||||
try {
|
||||
const definition = revision.genericExports?.find((candidate) => candidate.id === entry.application!.exportId);
|
||||
if (!definition) throw new Error("Missing generic export definition");
|
||||
const expected = specializePackageExport(revision, definition, entry.application.arguments, {
|
||||
arguments: new Map(),
|
||||
self: entry.application.self,
|
||||
applyInterface: (id, args) => {
|
||||
const applied = workspace.interfaceImports.find(
|
||||
(contract) =>
|
||||
contract.application?.definitionId === id && isDeepStrictEqual(contract.application.arguments, args),
|
||||
);
|
||||
if (applied) return applied.revisionId;
|
||||
const concrete = workspace.interfaceImports.find((contract) => contract.revisionId === id);
|
||||
if (concrete && !concrete.template && args.length === 0) return id;
|
||||
throw new Error(`Missing closed interface application ${id}`);
|
||||
},
|
||||
// Retained obligations are discharged against candidate conformances below.
|
||||
implementsInterface: (target, required) =>
|
||||
(revision.argumentRequirements ?? []).some(
|
||||
(obligation) => obligation.required === required && isDeepStrictEqual(obligation.target, target),
|
||||
),
|
||||
});
|
||||
if (!isDeepStrictEqual(entry, expected))
|
||||
throw new Error("Specialized export differs from its definition, source or arguments");
|
||||
} catch (error) {
|
||||
issue(issues, "invalid-operation", exportPath, error instanceof Error ? error.message : String(error));
|
||||
}
|
||||
}
|
||||
requireText(issues, entry.id, `${exportPath}.id`, "Package export ID");
|
||||
requireText(issues, entry.displayName, `${exportPath}.displayName`, "Package export name");
|
||||
if (exports.has(entry.id)) {
|
||||
@@ -655,8 +720,54 @@ const validateInterfaces = (
|
||||
issues: CapabilityValidationIssue[],
|
||||
indexes: ValidationIndexes,
|
||||
) => {
|
||||
const implies = (
|
||||
actual: InterfaceRevisionId,
|
||||
required: InterfaceRevisionId,
|
||||
visited = new Set<string>(),
|
||||
): boolean => {
|
||||
if (actual === required) return true;
|
||||
if (visited.has(actual)) return false;
|
||||
visited.add(actual);
|
||||
return (indexes.interfaces.get(actual)?.revision.requiredInterfaces ?? []).some((entry) =>
|
||||
implies(entry, required, visited),
|
||||
);
|
||||
};
|
||||
for (const [packageIndex, pkg] of workspace.packageImports.entries()) {
|
||||
for (const obligation of pkg.argumentRequirements ?? []) {
|
||||
const satisfied =
|
||||
obligation.target.kind === "atom"
|
||||
? indexes.conformances.has(conformanceKey(obligation.target.atomId, obligation.required))
|
||||
: implies(obligation.target.interfaceRevisionId, obligation.required);
|
||||
if (!satisfied)
|
||||
issue(
|
||||
issues,
|
||||
"unsatisfied-interface",
|
||||
`packageImports[${packageIndex}]`,
|
||||
`Generic argument does not implement ${obligation.required}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const [interfaceIndex, revision] of workspace.interfaceImports.entries()) {
|
||||
const path = `interfaceImports[${interfaceIndex}]`;
|
||||
for (const required of revision.requiredInterfaces ?? []) {
|
||||
if (!indexes.interfaces.has(required))
|
||||
issue(issues, "unresolved-reference", path, `Unknown prerequisite interface ${required}`);
|
||||
else if (implies(required, revision.revisionId))
|
||||
issue(
|
||||
issues,
|
||||
"cyclic-conformance-requirement",
|
||||
path,
|
||||
`Cyclic prerequisite involving ${revision.revisionId} and ${required}`,
|
||||
);
|
||||
}
|
||||
for (const obligation of revision.argumentRequirements ?? []) {
|
||||
const satisfied =
|
||||
obligation.target.kind === "atom"
|
||||
? indexes.conformances.has(conformanceKey(obligation.target.atomId, obligation.required))
|
||||
: implies(obligation.target.interfaceRevisionId, obligation.required);
|
||||
if (!satisfied)
|
||||
issue(issues, "unsatisfied-interface", path, `Generic argument does not implement ${obligation.required}`);
|
||||
}
|
||||
for (const [memberIndex, member] of revision.members.entries()) {
|
||||
const memberPath = `${path}.members[${memberIndex}]`;
|
||||
if (member.kind === "value") {
|
||||
@@ -1282,6 +1393,12 @@ const validateConformances = (
|
||||
}
|
||||
|
||||
const bindings = new Map<OperationId, Binding>();
|
||||
for (const required of interfaceEntry.revision.requiredInterfaces ?? []) {
|
||||
const requiredKey = conformanceKey(conformance.atomId, required);
|
||||
if (!indexes.conformances.has(requiredKey))
|
||||
issue(issues, "unsatisfied-interface", path, `Conformance requires ${conformance.atomId} as ${required}`);
|
||||
else requirementGraph.get(key)?.add(requiredKey);
|
||||
}
|
||||
for (const [bindingIndex, entry] of conformance.operationBindings.entries()) {
|
||||
const bindingPath = `${path}.operationBindings[${bindingIndex}]`;
|
||||
if (bindings.has(entry.operationId)) {
|
||||
@@ -1957,6 +2074,9 @@ export const computeCapabilityClosure = (
|
||||
atomId: conformance.source.atomId,
|
||||
interfaceRevisionId: conformance.source.interfaceRevisionId,
|
||||
});
|
||||
for (const interfaceRevisionId of plan.interfaces.get(root.interfaceRevisionId)?.requiredInterfaces ?? []) {
|
||||
queued.push({ atomId: root.atomId, interfaceRevisionId });
|
||||
}
|
||||
for (const binding of conformance.operationBindings.values()) {
|
||||
if (binding.kind === "state") {
|
||||
attachments.add(binding.slotId);
|
||||
|
||||
Reference in New Issue
Block a user