///|
pub(all) enum BindingChange {
SetBinding(String, Value)
RemoveBinding(String)
}
///|
pub(all) struct LinkChange {
name : String
before : String
after : String
} derive(Debug)
///|
priv struct SessionEndpoint {
name : String
template : Template
variables : Array[String]
contract : ContractTemplate?
}
///|
/// Immutable captured bindings and URI outputs; methods return detached arrays.
struct LinkSession {
endpoints : Array[SessionEndpoint]
dependencies : Map[String, Array[Int32]]
values : SessionBindings
outputs : Chunks[String]
sizes : Chunks[Int32]
positions : Map[String, Int32]
members_used : Int32
points_used : Int32
total : Int32
limits : ExpansionLimits
total_limit : Int32
patch_limit : Int32
}
///|
struct LinkUpdate {
next : LinkSession
changes : Array[LinkChange]
recomputed : Int32
}
///|
pub fn LinkUpdate::session(self : LinkUpdate) -> LinkSession {
self.next
}
///|
pub fn LinkUpdate::changes(self : LinkUpdate) -> Array[LinkChange] {
self.changes.copy()
}
///|
pub fn LinkUpdate::recomputed(self : LinkUpdate) -> Int32 {
self.recomputed
}
///|
pub fn LinkSession::links(self : LinkSession) -> Array[(String, String)] {
self.endpoints.mapi((i, endpoint) => (endpoint.name, self.outputs.at(i)))
}
///|
pub fn LinkSession::total_bytes(self : LinkSession) -> Int32 {
self.total
}
///|
pub fn LinkSession::get(
self : LinkSession,
name : String,
) -> Result[String, CatalogError] {
if self.positions.get(name) is Some(index) {
return Ok(self.outputs.at(index))
}
Err({
name,
error: problem("UnknownTemplate", "template name is not in session", 0, 0),
})
}
///|
fn session_problem(code : String, message : String) -> CatalogError {
{ name: "", error: problem(code, message, 0, 0), }
}
///|
fn SessionEndpoint::bindings(
self : SessionEndpoint,
shared : SessionBindings,
) -> Variables {
let values : Map[String, Value] = Map([])
for name in self.variables {
if shared.get(name) is Some(value) {
values[name] = value
}
}
// Internal read-only projection. Never exposed to a caller.
{ values, }
}
///|
fn SessionEndpoint::measure(
self : SessionEndpoint,
shared : SessionBindings,
limits : ExpansionLimits,
) -> Result[Int32, CatalogError] {
let vars = self.bindings(shared)
let measured = match self.contract {
Some(c) => c.measure(vars, limits~)
None => self.template.measure(vars, limits~)
}
measured.map_err(error => { name: self.name, error, })
}
///|
fn SessionEndpoint::render(
self : SessionEndpoint,
shared : SessionBindings,
limits : ExpansionLimits,
) -> Result[String, CatalogError] {
// All endpoints have already been validated and measured before rendering.
self.template
.expand_impl(self.bindings(shared), Some(limits))
.map_err(error => { name: self.name, error, })
}
///|
/// Contracts are optional endpoint policies and must refer to the same source.
/// Every shared input counts once against the session's cumulative input budget.
pub fn TemplateCatalog::start_session(
self : TemplateCatalog,
variables : Variables,
contracts~ : Array[(String, ContractTemplate)] = [],
limits~ : ExpansionLimits = ExpansionLimits::new(),
max_total_output_bytes~ : Int32 = 4194304,
max_patch_bindings~ : Int32 = 4096,
max_dependency_edges~ : Int32 = 65536,
) -> Result[LinkSession, CatalogError] {
if max_total_output_bytes < 0 ||
max_patch_bindings < 0 ||
max_dependency_edges < 0 {
return Err(session_problem("InvalidLimit", "negative session budget"))
}
match check_input_budget(variables, limits) {
Err(error) => return Err({ name: "", error, })
Ok(_) => ()
}
if contracts.length() > self.entries.length() {
return Err(session_problem("ContractLimit", "too many endpoint contracts"))
}
let policies : Map[String, ContractTemplate] = Map([])
for (name, contract) in contracts {
if policies.contains(name) {
return Err(
session_problem("DuplicateContract", "duplicate endpoint contract"),
)
}
match self.entries.get(name) {
None =>
return Err({
name,
error: problem("UnknownTemplate", "unknown contract endpoint", 0, 0),
})
Some(t) =>
if t.source_text != contract.template.source_text {
return Err({
name,
error: problem(
"ContractSource", "contract source does not match endpoint", 0, 0,
),
})
}
}
policies[name] = contract
}
let endpoints : Array[SessionEndpoint] = []
let dependencies : Map[String, Array[Int32]] = Map([])
let mut remaining_edges = max_dependency_edges
for name in self.names() {
let template = self.entries[name]
let names = template.variables()
if names.length() > remaining_edges {
return Err(
session_problem(
"DependencyLimit", "endpoint dependencies exceed budget",
),
)
}
remaining_edges = remaining_edges - names.length()
for variable in names {
match dependencies.get(variable) {
Some(indices) => indices.push(endpoints.length())
None => dependencies[variable] = [endpoints.length()]
}
}
endpoints.push({
name,
template,
variables: names,
contract: policies.get(name),
})
}
for name in variables.values.keys() {
if !dependencies.contains(name) {
return Err(
session_problem(
"UnusedBinding", "shared binding is not used by any endpoint",
),
)
}
}
let captured = SessionBindings::capture(
dependencies.keys().to_array(),
variables,
)
let mut members_used = 0
let mut points_used = 0
for name, value in variables.values {
let (members, points) = binding_cost(name, value)
members_used = members_used + members
points_used = points_used + points
}
let positions : Map[String, Int32] = Map([])
for index, endpoint in endpoints {
positions[endpoint.name] = index
}
let sizes : Array[Int32] = []
let mut remaining = max_total_output_bytes
for endpoint in endpoints {
let size = match endpoint.measure(captured, limits) {
Err(e) => return Err(e)
Ok(size) => size
}
if size > remaining {
return Err(
session_problem("SessionOutputLimit", "snapshot output exceeds budget"),
)
}
remaining = remaining - size
sizes.push(size)
}
let outputs : Array[String] = []
for endpoint in endpoints {
match endpoint.render(captured, limits) {
Err(e) => return Err(e)
Ok(uri) => outputs.push(uri)
}
}
Ok({
endpoints,
dependencies,
values: captured,
outputs: Chunks::from_array(outputs),
sizes: Chunks::from_array(sizes),
positions,
members_used,
points_used,
total: max_total_output_bytes - remaining,
limits,
total_limit: max_total_output_bytes,
patch_limit: max_patch_bindings,
})
}
///|
fn same_value(a : Value, b : Value) -> Bool {
match (a, b) {
(Scalar(a), Scalar(b)) => a == b
(List(a), List(b)) => a == b
(Assoc(a), Assoc(b)) => a.entries == b.entries
_ => false
}
}
///|
/// Stage a patch, validate the complete next environment and total output, then
/// render only affected endpoints. Neither failure nor success mutates self.
pub fn LinkSession::update(
self : LinkSession,
changes : Array[BindingChange],
) -> Result[LinkUpdate, CatalogError] {
if changes.length() > self.patch_limit {
return Err(session_problem("PatchLimit", "too many patch operations"))
}
let seen : Map[String, Bool] = Map([])
let incoming = Variables::new()
for change in changes {
let name = match change {
SetBinding(name, _) | RemoveBinding(name) => name
}
if seen.contains(name) {
return Err(
session_problem("DuplicatePatch", "a binding occurs twice in patch"),
)
}
seen[name] = true
if !self.dependencies.contains(name) {
return Err(
session_problem(
"UnusedBinding", "patch binding is not used by any endpoint",
),
)
}
if change is SetBinding(_, value) {
// Validate incoming content BEFORE copying composite values.
incoming.values[name] = value
}
}
match check_input_budget(incoming, self.limits) {
Err(error) => return Err({ name: "", error, })
Ok(_) => ()
}
let (next, changed_names) = self.values.stage(changes)
let affected : Map[Int32, Bool] = Map([])
for name in changed_names {
for index in self.dependencies[name] {
affected[index] = true
}
}
// Only changed bindings are re-counted; unchanged ones were checked before.
if next.active.length() > self.limits.max_bindings {
return Err(session_problem("BindingLimit", "binding count exceeds budget"))
}
let mut members_used = self.members_used
let mut points_used = self.points_used
for name in changed_names {
if self.values.get(name) is Some(old) {
let (members, points) = binding_cost(name, old)
members_used = members_used - members
points_used = points_used - points
}
if next.get(name) is Some(value) {
let (members, points) = binding_cost(name, value)
members_used = members_used + members
points_used = points_used + points
}
}
if members_used > self.limits.max_members {
return Err(
session_problem("MemberLimit", "total collection members exceed budget"),
)
}
if points_used > self.limits.max_total_value_codepoints {
return Err(
session_problem(
"TotalValueLimit", "total input code points exceed budget",
),
)
}
if affected.is_empty() {
return Ok({ next: self, changes: [], recomputed: 0, })
}
// Preserve catalog order without traversing every unchanged endpoint twice.
let indices = affected.keys().to_array()
indices.sort()
// Release ALL affected lengths first, so growth/shrink is order-independent.
let mut remaining = self.total_limit - self.total
for index in indices {
remaining = remaining + self.sizes.at(index)
}
let sizes : Array[Int32] = []
for index in indices {
let endpoint = self.endpoints[index]
let size = match endpoint.measure(next, self.limits) {
Err(e) => return Err(e)
Ok(size) => size
}
if size > remaining {
return Err(
session_problem("SessionOutputLimit", "snapshot output exceeds budget"),
)
}
remaining = remaining - size
sizes.push(size)
}
let outputs : Array[String] = []
let emitted : Array[LinkChange] = []
for index in indices {
let endpoint = self.endpoints[index]
let uri = match endpoint.render(next, self.limits) {
Err(e) => return Err(e)
Ok(uri) => uri
}
let before = self.outputs.at(index)
if uri != before {
emitted.push({ name: endpoint.name, before, after: uri, })
}
outputs.push(uri)
}
// Copy only changed composites; unchanged captured values remain private.
next.capture_changed(changed_names)
Ok({
next: {
..self,
values: next,
outputs: self.outputs.with_updates(indices, outputs),
sizes: self.sizes.with_updates(indices, sizes),
members_used,
points_used,
total: self.total_limit - remaining,
},
changes: emitted,
recomputed: affected.length(),
})
}
///|
pub extend LinkChange with Debug::{to_repr}