/** * One helper per kind, one object each, the actions inside it: * * service({ name, selectors, attributes, actions: { create, update, delete, "custom": { …spec, handler } } }) * link(service, { name, attributes, actions: { create, update, delete } }) * scope({ name, attributes, scopeType, telemetry, actions: { [Actions.CREATE_SCOPE]: fn, … } }) * * Each takes the API's own fields in camelCase — `useDefaultActions` IS * `use_default_actions` — and returns a Resource whose actions are bound. * What is wrong is said here, at definition, before anything is published. */ import type { FromExtendedSchema } from "json-schema-to-ts"; import type { NpJSONSchema, NpKeywords } from "../schema"; import type { PlanDeclaration } from "./tracing"; import { getActionDefaults, WELL_KNOWN_ACTIONS } from "../scope/actions"; import type { HandlerContext, HandlerFn } from "./handler"; import { resource, type Resource } from "./resource"; // ─── Bodies: the API's fields, camelCase ──────────────────────────── export interface Selectors { category?: string; provider?: string; subCategory?: string; imported?: boolean; } export interface Attributes { schema: NpJSONSchema; values?: Record; } /** A custom action: its action_specification fields, plus the handler. */ export interface ActionDef

, R = unknown> { name?: string; /** `custom` unless the platform knows the slug (scope actions). */ type?: string; retryable?: boolean; parallelize?: boolean; icon?: string; enabledWhen?: Record; parameters?: { schema: NpJSONSchema; values?: Record }; results?: { schema: NpJSONSchema; values?: Record }; handler: HandlerFn, P, R>; } /** The action_specification body, as published. */ export interface ActionSpecificationDef extends Omit { slug: string; name: string; } export type ActionEntry

> = | HandlerFn, P, unknown> | ActionDef

; /** A service's or link's actions: the default ones as functions, custom ones with their specification. */ export type ServiceActionMap

> = { create?: HandlerFn, P, unknown>; update?: HandlerFn, P, unknown>; delete?: HandlerFn, P, unknown>; } & { [custom: string]: ActionEntry

| undefined }; /** A scope's actions: well-known lifecycle slugs and telemetry data actions as functions, custom ones with their specification. */ export type ScopeActionMap

> = { [slug: string]: ActionEntry

; }; interface SpecificationCommon { /** Defaults to the name, slugified. */ slug?: string; name: string; unique?: boolean; assignableTo?: "any" | "dimension" | "scope"; useDefaultActions?: boolean; useDefaultNaming?: boolean; useManagedActions?: boolean; selectors?: Selectors; attributes: Attributes; /** The console's form layout, stored by the platform inside `attributes.schema.uiSchema`. */ uiSchema?: Record; /** * The steps each action is expected to run, by action slug — the run's plan * on the trace, which the console's timeline needs to tell the story. A * function names them from the notification (a job to run only when one is * set). Never published: the specification does not carry it. */ plans?: Record; dimensions?: Record; scopes?: Record; visibleTo?: string[]; } export interface ServiceDef extends SpecificationCommon { /** `dependency` (default) or `scope`. */ type?: "dependency" | "scope"; actions?: ServiceActionMap; } export interface LinkDef extends SpecificationCommon { actions?: ServiceActionMap; } export interface ScopeTypeDef { /** Defaults to the scope's name. */ name?: string; description?: string; /** `custom` unless the owner says otherwise. */ type?: string; status?: "active" | "inactive"; } export interface ScopeDef extends Omit { /** Asset type the scope deploys; injected into the schema as a hidden property. Default `docker-image`. */ assetType?: string; /** The scope type published with the scope. None: no scope_type component. */ scopeType?: ScopeTypeDef; /** Declares the telemetry data actions (`log:read`, `instance:data`) the scope answers. */ telemetry?: { logs?: boolean; instances?: boolean }; actions: ScopeActionMap; } // ─── Typing from the declared schema ───────────────────────────────── export const DEFAULT_ACTIONS = ["create", "update", "delete"] as const; /** * The attribute values a declaration's schema describes, inferred from the * schema literal: `service({ attributes: { schema: {...} } })` gives its * handlers `ctx.params` typed without any generated code. */ export type AttributesOf = B extends { attributes: { schema: infer S } } ? SchemaValues : Record; /** The values a schema literal describes, loose: every declared key optional, unknown keys allowed. */ export type SchemaValues = S extends NpJSONSchema ? Partial> & Record : Record; /** The context a handler of resource `R` receives. */ export type ContextOf = HandlerContext< Record, AttributesOf >; /** * The context of a function bound to a resource declared with `schema`, for * functions written in their own file: `type Context = ContextFor` * (the schema declared `as const satisfies NpJSONSchema`). Identical to what * the resource's `actions` expect, so the binding needs no structural check. */ export type ContextFor = HandlerContext< Record, SchemaValues >; export interface Specification extends Resource { /** The action keys this resource answers, in declaration order. */ actions(): string[]; /** The bound action functions by key (what dispatch runs). */ readonly handlers: Record>; } /** The body a helper publishes: the declaration's fields, the defaults filled, the action specifications derived. */ export interface SpecificationBody extends Omit { type: string; useDefaultActions: boolean; attributes: { schema: S; values?: Record }; actionSpecifications?: ActionSpecificationDef[]; telemetry?: { logs?: boolean; instances?: boolean }; } type Fields = Omit; type Attrs = { schema: S; values?: Record }; /** What `service()` takes: the fields, the schema `S`, and actions typed from `S`. */ export type ServiceDefOf = Fields & { attributes: Attrs; actions?: ServiceActionMap>; }; export type LinkDefOf = Fields & { attributes: Attrs; actions?: ServiceActionMap>; }; export type ScopeDefOf = Fields & { attributes: Attrs; actions: ScopeActionMap>; }; export type ServiceResource = Specification< "service_specification", SpecificationBody >; export type LinkResource = Specification< "link_specification", SpecificationBody >; export type ScopeResource = Specification< "service_specification", SpecificationBody >; export type ScopeTypeResource = Resource< "scope_type", ScopeTypeDef & Record >; // ─── Binding ───────────────────────────────────────────────────────── const isFn = (v: unknown): v is HandlerFn => typeof v === "function"; const isActionDef = (v: unknown): v is ActionDef => !!v && typeof v === "object" && !Array.isArray(v); interface Bound { handlers: Record>; specs: ActionSpecificationDef[]; keys: string[]; } /** Splits an actions map into bound functions and the action specifications the custom entries declare. */ function bind( where: string, actions: Record | undefined>, rules: { defaults: boolean; wellKnown: (slug: string) => boolean; /** The results schema of a well-known lifecycle action bound as a plain function (a scope's outputs). */ results?: NpJSONSchema; } ): Bound { const out: Bound = { handlers: {}, specs: [], keys: [] }; const problems: string[] = []; for (const [slug, entry] of Object.entries(actions)) { if (entry === undefined) continue; const isDefault = (DEFAULT_ACTIONS as readonly string[]).includes(slug); if (isFn(entry)) { if (isDefault && !rules.defaults) problems.push( `"${slug}" is a default action, and useDefaultActions is false` ); else if (!isDefault && !rules.wellKnown(slug)) problems.push( `"${slug}" is not a well-known action: declare it as { name, parameters, results, handler }` ); out.handlers[slug] = entry; out.keys.push(slug); if (!isDefault && rules.wellKnown(slug)) { const d = getActionDefaults(slug); out.specs.push({ slug, name: d.name, type: d.type, retryable: d.retryable, // The platform copies into the service's attributes only the result // keys the action's results schema declares: a lifecycle action // declares the scope's outputs, or nothing it returns ever shows. ...(d.parameters ? { parameters: d.parameters } : {}), ...(d.lifecycle && rules.results ? { results: { schema: rules.results } } : {}), }); } continue; } if (!isActionDef(entry)) { problems.push( `"${slug}" must be a handler function or { …action specification, handler }` ); continue; } if (typeof entry.handler !== "function") { problems.push(`"${slug}" has no handler`); continue; } if (isDefault) problems.push( `"${slug}" is a default action: bind a function, not an action specification` ); const { handler, ...spec } = entry; const d = getActionDefaults(slug); out.handlers[slug] = handler; out.keys.push(slug); out.specs.push({ slug, name: spec.name ?? d.name, type: spec.type ?? d.type, retryable: spec.retryable ?? d.retryable, ...spec, ...(spec.name ? {} : { name: d.name }), }); } if (rules.defaults) { const missing = DEFAULT_ACTIONS.filter((a) => !out.handlers[a]); if (missing.length) problems.push( `no handler for ${missing .map((m) => `"${m}"`) .join(", ")} (useDefaultActions is true)` ); } if (problems.length) throw new Error(`${where}: ${problems.join("; ")}`); return out; } function withUiSchema( attributes: Attributes, uiSchema?: Record ): Attributes { if (!uiSchema) return attributes; return { ...attributes, schema: { ...(attributes.schema as object), uiSchema, } as unknown as NpJSONSchema, }; } function specification( res: Resource, bound: Bound ): Specification { return Object.assign(res, { actions: () => bound.keys.slice(), handlers: bound.handlers, }); } // ─── The helpers ───────────────────────────────────────────────────── /** A dependency service: `POST /service_specification` with `type: "dependency"`, its actions bound. */ export function service( def: ServiceDefOf ): ServiceResource { const { slug, name, actions, uiSchema, attributes, ...rest } = def as ServiceDef; const defaults = def.useDefaultActions !== false; const bound = bind(`service(${name})`, actions ?? {}, { defaults, wellKnown: () => false, }); const body = { slug, name, type: "dependency", useDefaultActions: defaults, ...rest, attributes: withUiSchema(attributes, uiSchema), ...(bound.specs.length ? { actionSpecifications: bound.specs } : {}), } as unknown as SpecificationBody; return specification(resource("service_specification", body), bound); } /** A link of a service: `POST /link_specification` under that specification, its actions bound. */ export function link( parent: Resource<"service_specification", any>, def: LinkDefOf ): LinkResource { const { slug, name, actions, uiSchema, attributes, ...rest } = def as LinkDef; const defaults = def.useDefaultActions !== false; const bound = bind(`link(${name})`, actions ?? {}, { defaults, wellKnown: () => false, }); const body = { slug, name, useDefaultActions: defaults, ...rest, attributes: withUiSchema(attributes, uiSchema), ...(bound.specs.length ? { actionSpecifications: bound.specs } : {}), } as unknown as SpecificationBody; return specification(resource("link_specification", body, { parent }), bound); } /** * A scope: a `service_specification` with `type: "scope"` whose actions are the * platform's well-known scope actions and telemetry data actions, declared as * action specifications the way the CLI registers them, plus the hidden * `asset_type` property and, when declared, the scope type. */ export function scope( def: ScopeDefOf ): ScopeResource { const { slug, name, actions, assetType, scopeType, telemetry, uiSchema, attributes, ...rest } = def as ScopeDef; const dataAction = (s: string) => !getActionDefaults(s).lifecycle; const bound = bind(`scope(${name})`, actions, { defaults: false, wellKnown: (s) => s in WELL_KNOWN_ACTIONS || dataAction(s), results: outputsOf(attributes), }); const problems: string[] = []; const has = (s: string) => s in bound.handlers; if (has("log:read") && !telemetry?.logs) problems.push(`"log:read" is bound but telemetry.logs is not declared`); if (telemetry?.logs && !has("log:read")) problems.push(`telemetry.logs is declared but "log:read" is not bound`); if (has("instance:data") && !telemetry?.instances) problems.push( `"instance:data" is bound but telemetry.instances is not declared` ); if (telemetry?.instances && !has("instance:data")) problems.push( `telemetry.instances is declared but "instance:data" is not bound` ); if (problems.length) throw new Error(`scope(${name}): ${problems.join("; ")}`); const body = { slug, name, type: "scope", useDefaultActions: false, ...rest, attributes: withAssetType( withUiSchema(attributes, uiSchema), assetType ?? "docker-image" ), actionSpecifications: bound.specs, ...(telemetry ? { telemetry } : {}), } as unknown as SpecificationBody; const res = specification(resource("service_specification", body), bound); if (scopeType) { resource( "scope_type", { type: "custom", status: "active", ...scopeType, name: scopeType.name ?? name, provider: res.ref, } as ScopeTypeDef & Record, { parent: res, slug: res.slug } ); } return res; } /** * What a scope's lifecycle actions may report back: every attribute but * `asset_type` (the platform's own), with nothing required. The platform * applies to the scope's service only the result keys an action's results * schema names. */ export function outputsOf(attributes: Attributes): NpJSONSchema { const schema = attributes.schema as Record & { properties?: Record; }; const properties = Object.fromEntries( Object.entries(schema.properties ?? {}).filter(([k]) => k !== "asset_type") ); return { type: "object", properties } as NpJSONSchema; } export function withAssetType( attributes: Attributes, assetType: string ): Attributes { const schema = attributes.schema as Record & { properties?: Record; }; return { ...attributes, schema: { ...schema, properties: { asset_type: { type: "string", export: false, readOnly: true, default: assetType, }, ...(schema.properties ?? {}), }, } as unknown as NpJSONSchema, }; } export const isSpecification = ( v: unknown ): v is Specification => !!v && typeof v === "object" && typeof (v as Specification).actions === "function" && typeof (v as Specification).handlers === "object";