feat: add module runtime lifecycle
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
export * from "./identifiers.js";
|
||||
export * from "./module-lifecycle.js";
|
||||
export * from "./module-manifest.js";
|
||||
export * from "./module-runtime.js";
|
||||
export * from "./versions.js";
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
import { semanticVersionRangeSchema, semanticVersionSchema } from "./versions.js";
|
||||
|
||||
const moduleVersionMapSchema = z.record(moduleIdSchema, semanticVersionRangeSchema);
|
||||
const capabilityVersionMapSchema = z.record(capabilityIdSchema, semanticVersionRangeSchema);
|
||||
|
||||
const moduleDependenciesSchema = z
|
||||
.object({
|
||||
@@ -20,11 +21,11 @@ const moduleDependenciesSchema = z
|
||||
|
||||
const capabilityDependenciesSchema = z
|
||||
.object({
|
||||
required: z.array(capabilityIdSchema).default(() => []),
|
||||
optional: z.array(capabilityIdSchema).default(() => []),
|
||||
required: capabilityVersionMapSchema.default(() => ({})),
|
||||
optional: capabilityVersionMapSchema.default(() => ({})),
|
||||
})
|
||||
.strict()
|
||||
.default(() => ({ required: [], optional: [] }));
|
||||
.default(() => ({ required: {}, optional: {} }));
|
||||
|
||||
const dependenciesSchema = z
|
||||
.object({
|
||||
@@ -34,9 +35,16 @@ const dependenciesSchema = z
|
||||
.strict()
|
||||
.default(() => ({
|
||||
modules: { required: {}, optional: {} },
|
||||
capabilities: { required: [], optional: [] },
|
||||
capabilities: { required: {}, optional: {} },
|
||||
}));
|
||||
|
||||
export const moduleCapabilityDeclarationSchema = z
|
||||
.object({
|
||||
id: capabilityIdSchema,
|
||||
version: semanticVersionSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const moduleBucketDeclarationSchema = z
|
||||
.object({
|
||||
id: logicalBucketIdSchema,
|
||||
@@ -64,6 +72,7 @@ function reportDuplicates(
|
||||
values: readonly string[],
|
||||
path: readonly (string | number)[],
|
||||
context: z.RefinementCtx,
|
||||
property?: string,
|
||||
): void {
|
||||
const seen = new Set<string>();
|
||||
values.forEach((value, index) => {
|
||||
@@ -71,7 +80,7 @@ function reportDuplicates(
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `${value} is declared more than once.`,
|
||||
path: [...path, index],
|
||||
path: [...path, index, ...(property ? [property] : [])],
|
||||
});
|
||||
}
|
||||
seen.add(value);
|
||||
@@ -80,7 +89,7 @@ function reportDuplicates(
|
||||
|
||||
export const moduleManifestSchema = z
|
||||
.object({
|
||||
manifestVersion: z.literal(1),
|
||||
manifestVersion: z.literal(2),
|
||||
id: moduleIdSchema,
|
||||
name: z.string().trim().min(1).max(120),
|
||||
description: z.string().trim().min(1).max(500).optional(),
|
||||
@@ -92,37 +101,33 @@ export const moduleManifestSchema = z
|
||||
})
|
||||
.strict(),
|
||||
dependencies: dependenciesSchema,
|
||||
capabilities: z.array(capabilityIdSchema).default(() => []),
|
||||
capabilities: z.array(moduleCapabilityDeclarationSchema).default(() => []),
|
||||
permissions: z.array(permissionIdSchema).default(() => []),
|
||||
storage: moduleStorageSchema,
|
||||
entrypoints: entrypointsSchema,
|
||||
})
|
||||
.strict()
|
||||
.superRefine((manifest, context) => {
|
||||
reportDuplicates(manifest.capabilities, ["capabilities"], context);
|
||||
reportDuplicates(
|
||||
manifest.capabilities.map((capability) => capability.id),
|
||||
["capabilities"],
|
||||
context,
|
||||
"id",
|
||||
);
|
||||
reportDuplicates(manifest.permissions, ["permissions"], context);
|
||||
reportDuplicates(
|
||||
manifest.dependencies.capabilities.required,
|
||||
["dependencies", "capabilities", "required"],
|
||||
context,
|
||||
);
|
||||
reportDuplicates(
|
||||
manifest.dependencies.capabilities.optional,
|
||||
["dependencies", "capabilities", "optional"],
|
||||
context,
|
||||
);
|
||||
reportDuplicates(
|
||||
manifest.storage.buckets.map((bucket) => bucket.id),
|
||||
["storage", "buckets"],
|
||||
context,
|
||||
"id",
|
||||
);
|
||||
|
||||
manifest.capabilities.forEach((capability, index) => {
|
||||
if (!capability.startsWith(`${manifest.id}.`)) {
|
||||
if (!capability.id.startsWith(`${manifest.id}.`)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `Capability identifiers owned by this module must begin with ${manifest.id}.`,
|
||||
path: ["capabilities", index],
|
||||
path: ["capabilities", index, "id"],
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -157,33 +162,27 @@ export const moduleManifestSchema = z
|
||||
}
|
||||
}
|
||||
|
||||
const ownedCapabilities = new Set(manifest.capabilities);
|
||||
const requiredCapabilities = new Set(manifest.dependencies.capabilities.required);
|
||||
manifest.dependencies.capabilities.required.forEach((capability, index) => {
|
||||
if (ownedCapabilities.has(capability)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A module does not need to depend on a capability it owns.",
|
||||
path: ["dependencies", "capabilities", "required", index],
|
||||
});
|
||||
const ownedCapabilities = new Set(manifest.capabilities.map((capability) => capability.id));
|
||||
for (const dependencyKind of ["required", "optional"] as const) {
|
||||
for (const capability of Object.keys(manifest.dependencies.capabilities[dependencyKind])) {
|
||||
if (ownedCapabilities.has(capability)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A module does not need to depend on a capability it owns.",
|
||||
path: ["dependencies", "capabilities", dependencyKind, capability],
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
manifest.dependencies.capabilities.optional.forEach((capability, index) => {
|
||||
if (ownedCapabilities.has(capability)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: "A module does not need to depend on a capability it owns.",
|
||||
path: ["dependencies", "capabilities", "optional", index],
|
||||
});
|
||||
}
|
||||
if (requiredCapabilities.has(capability)) {
|
||||
}
|
||||
for (const capability of Object.keys(manifest.dependencies.capabilities.required)) {
|
||||
if (Object.hasOwn(manifest.dependencies.capabilities.optional, capability)) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
message: `${capability} cannot be both a required and optional capability dependency.`,
|
||||
path: ["dependencies", "capabilities", "optional", index],
|
||||
path: ["dependencies", "capabilities", "optional", capability],
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export function parseModuleManifest(input: unknown): ModuleManifest {
|
||||
@@ -197,3 +196,4 @@ export function safeParseModuleManifest(input: unknown) {
|
||||
export type ModuleManifestInput = z.input<typeof moduleManifestSchema>;
|
||||
export type ModuleManifest = z.output<typeof moduleManifestSchema>;
|
||||
export type ModuleBucketDeclaration = z.output<typeof moduleBucketDeclarationSchema>;
|
||||
export type ModuleCapabilityDeclaration = z.output<typeof moduleCapabilityDeclarationSchema>;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { CapabilityId, ModuleId } from "./identifiers.js";
|
||||
import { moduleIdSchema } from "./identifiers.js";
|
||||
import { semanticVersionSchema } from "./versions.js";
|
||||
|
||||
export type Awaitable<Value> = Value | PromiseLike<Value>;
|
||||
|
||||
export interface RegisteredCapability {
|
||||
readonly id: CapabilityId;
|
||||
readonly implementation: unknown;
|
||||
readonly moduleId: ModuleId;
|
||||
readonly version: string;
|
||||
}
|
||||
|
||||
export interface CapabilityResolver {
|
||||
resolve(capabilityId: CapabilityId): RegisteredCapability | undefined;
|
||||
}
|
||||
|
||||
export interface CapabilityRegistrar extends CapabilityResolver {
|
||||
provide(capabilityId: CapabilityId, implementation: unknown): void;
|
||||
}
|
||||
|
||||
export interface ModuleRegistrationContext {
|
||||
readonly capabilities: CapabilityRegistrar;
|
||||
readonly moduleId: ModuleId;
|
||||
}
|
||||
|
||||
export interface ModuleRuntimeContext {
|
||||
readonly capabilities: CapabilityResolver;
|
||||
readonly moduleId: ModuleId;
|
||||
}
|
||||
|
||||
export interface ModuleRuntimeDefinition {
|
||||
readonly id: ModuleId;
|
||||
readonly register?: (context: ModuleRegistrationContext) => Awaitable<void>;
|
||||
readonly start?: (context: ModuleRuntimeContext) => Awaitable<void>;
|
||||
readonly stop?: (context: ModuleRuntimeContext) => Awaitable<void>;
|
||||
readonly version: string;
|
||||
}
|
||||
|
||||
const runtimeDefinitionKeys = new Set(["id", "register", "start", "stop", "version"]);
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
export function isModuleRuntimeDefinition(value: unknown): value is ModuleRuntimeDefinition {
|
||||
if (
|
||||
!isRecord(value) ||
|
||||
!moduleIdSchema.safeParse(value.id).success ||
|
||||
!semanticVersionSchema.safeParse(value.version).success
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (Object.keys(value).some((key) => !runtimeDefinitionKeys.has(key))) {
|
||||
return false;
|
||||
}
|
||||
return [value.register, value.start, value.stop].every(
|
||||
(hook) => hook === undefined || typeof hook === "function",
|
||||
);
|
||||
}
|
||||
|
||||
export function parseModuleRuntimeDefinition(value: unknown): ModuleRuntimeDefinition {
|
||||
if (!isModuleRuntimeDefinition(value)) {
|
||||
throw new TypeError("Invalid Ekklesia module runtime definition.");
|
||||
}
|
||||
return Object.freeze({
|
||||
id: value.id,
|
||||
version: value.version,
|
||||
...(value.register ? { register: value.register } : {}),
|
||||
...(value.start ? { start: value.start } : {}),
|
||||
...(value.stop ? { stop: value.stop } : {}),
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user