@workspec/topology-model
v0.1.0-alpha.1
Published
Pure loader/resolver for a WorkSpec Topology working tree — discovers, parses, and resolves resources, environments, and connections from any file-source port into one typed, environment-scoped ResolvedTopology plus network/resource-group lens trees, with
Maintainers
Readme
@workspec/topology-model
Pure loader/resolver for a WorkSpec Topology working tree. Discovers .workspec/ topologies,
resources, environments, and .layout/ files through a small file-source port, parses and
validates every one via @workspec/topology-schema, and provides the
normative resolve() contract that turns a raw Topology into an environment-scoped
ResolvedTopology — the shape every downstream consumer (the Studio UI, cost, traceability) takes
instead of the raw artifact. Also builds both normative lens trees (network, resource-group) over a
resolved topology. Never throws: every failure mode degrades to a diagnostic, and the model/result
is always data-complete.
No DOM, no React, no IO assumptions — the root entry only knows about MemorySource. Node-only
code (FsSource, node:fs/promises) lives behind the ./fs subpath export, so importing
@workspec/topology-model itself never pulls in a node: module.
Usage
import {
loadTopologyModel,
resolve,
buildNetworkTree,
buildResourceGroupTree,
createMemorySource,
} from '@workspec/topology-model';
import { createFsSource } from '@workspec/topology-model/fs';
// Server-side / CLI: read a real working tree from disk.
const model = await loadTopologyModel(createFsSource('/path/to/repo'));
if (model.topology) {
const resources = new Map([...model.resources].map(([slug, r]) => [slug, r.resource]));
const environments = new Map([...model.environments].map(([slug, e]) => [slug, e.environment]));
const resolved = resolve(model.topology.topology, resources, environments, 'prod');
const network = buildNetworkTree(resolved);
const resourceGroups = buildResourceGroupTree(resolved);
}The resolve() contract
Applied in order, for one target environment:
Prune resources — drop any resource whose
spec.environmentsis present and excludes the target environment. Omittedenvironmentsmeans present in every environment; this omission is meaningful and is never defaulted to an empty list.Prune connections — drop a connection whose own
environmentsexcludes the target environment (explicit scoping, enabling per-environment rewiring), OR whosefrom/towas pruned in step 1 (auto-prune).Merge overrides (S1) — each surviving resource's OWN
spec.overrides[envSlug]patch is merged onto itsconfig/cost/resourceGroup/network.resolve()itself doesn't validate the override key — an override for an environment this resource isn't present in, or that doesn't exist at all, is caught upstream by this package'scheckOverrideEnvironmentRefs(verify-time; see its own doc comment for why BOTH of those rules live at the model layer, not schema-level) — it just applies whatever the targetenvSlugnames, per field:config— a SHALLOW, top-level merge: a key named in the override replaces the base value at that key wholesale, even when both are objects — this is NOT a deep/recursive merge, and anulloverride value SETS the merged key tonullrather than removing it (there is no way to make an override key "disappear" from the merged result).cost— field-by-field (sku/mode/schedule/qty/attribution);attributionis a whole-array replace, never an element-wise splice.resourceGroup/network— full replace of the base ref when the override names one. Since both fields areSlug-typed (never nullable), an override can only REPLACE a resource's placement with a different one, never clear it to "no placement" for one specific environment while the base resource has one.
(v0 sourced this patch from
Environment.spec.overrides[slug]instead; S1 moved it onto the Resource — see@workspec/topology-schema'sresource.ts.)Apply naming — a
resource-group-kind resource's resolved display name becomes<rg-slug><suffix>when the matchingEnvironmentdeclaresnaming.resourceGroupSuffix; its own authorednameis untouched. Naming is the one thingresolve()still reads from theEnvironmentartifact.
Lens trees (spec §3.2)
A resource's kind alone decides whether it renders as a container box or a plain node, and this
is lens-scoped: vnet/subnet are container boxes in the network lens (nested via each
resource's own network ref) but plain nodes in the resource-group lens; resource-group is the
container box in the resource-group lens (nested via resourceGroup) but a plain node in the
network lens. buildNetworkTree/buildResourceGroupTree apply this one rule via
isGroupingKindForLens — there is no separate case for either lens's "nodes inside the other
kind's box" behaviour.
