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:
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user