Current section
Files
Jump to
Current section
Files
src/lightspeed/component/nested_lifecycle.gleam
//// First-class nested LiveView lifecycle primitives for M55.
import gleam/int
import gleam/list
import gleam/option.{type Option, None, Some}
import lightspeed/diff
/// Child lifecycle status in one nested tree.
pub type ChildLifecycle {
Mounted
Suspended
Failed(reason: String)
Terminated
}
/// Event scope for authority-safe routing.
pub type EventScope {
ParentScope
ChildScope(child_id: String)
}
/// One scoped inbound event.
pub type ScopedEvent {
ScopedEvent(scope: EventScope, name: String, payload: String)
}
/// Event routing decision.
pub type RouteDecision {
RoutedToParent
RoutedToChild(child_id: String)
Rejected(reason: String)
}
/// One child runtime record.
pub type ChildNode {
ChildNode(
id: String,
identity: String,
lifecycle: ChildLifecycle,
mount_count: Int,
update_count: Int,
fingerprint: String,
)
}
/// Nested parent/child runtime.
pub opaque type Runtime {
Runtime(
parent_id: String,
route: String,
parent_target: String,
parent_mount_count: Int,
children_rev: List(ChildNode),
patches_rev: List(diff.Patch),
lifecycle_events_rev: List(String),
routed_events_rev: List(String),
authority_violations_rev: List(String),
)
}
/// Start one parent runtime.
pub fn start(parent_id: String, route: String) -> #(Runtime, List(diff.Patch)) {
let target = "#parent-" <> parent_id
let patch =
diff.Replace(
target: target,
html: "<main data-parent=\"" <> parent_id <> "\"></main>",
)
let runtime =
Runtime(
parent_id: parent_id,
route: route,
parent_target: target,
parent_mount_count: 1,
children_rev: [],
patches_rev: [patch],
lifecycle_events_rev: ["parent_mounted:" <> route],
routed_events_rev: [],
authority_violations_rev: [],
)
#(runtime, [patch])
}
/// Build one scoped event.
pub fn scoped_event(
scope: EventScope,
name: String,
payload: String,
) -> ScopedEvent {
ScopedEvent(scope: scope, name: name, payload: payload)
}
/// Mount a child boundary with stable identity.
pub fn mount_child(
runtime: Runtime,
child_id: String,
fingerprint: String,
html: String,
) -> #(Runtime, List(diff.Patch)) {
case child(runtime, child_id) {
Some(existing) ->
case existing.lifecycle {
Terminated -> {
let remounted =
ChildNode(
..existing,
lifecycle: Mounted,
mount_count: existing.mount_count + 1,
fingerprint: fingerprint,
)
let patch =
diff.Replace(target: child_target(runtime, child_id), html: html)
#(
Runtime(
..runtime,
children_rev: replace_child(
runtime.children_rev,
child_id,
remounted,
),
patches_rev: append_ordered_to_rev(runtime.patches_rev, [patch]),
lifecycle_events_rev: append_ordered_to_rev(
runtime.lifecycle_events_rev,
[
"child_remounted:" <> child_id,
],
),
),
[patch],
)
}
Mounted | Suspended | Failed(_) -> {
let resumed =
ChildNode(..existing, lifecycle: Mounted, fingerprint: fingerprint)
let patch =
diff.UpdateSegments(
target: child_target(runtime, child_id),
fingerprint: fingerprint,
dynamic_slots: [diff.slot("html", html)],
)
#(
Runtime(
..runtime,
children_rev: replace_child(
runtime.children_rev,
child_id,
resumed,
),
patches_rev: append_ordered_to_rev(runtime.patches_rev, [patch]),
lifecycle_events_rev: append_ordered_to_rev(
runtime.lifecycle_events_rev,
[
"child_mounted_existing:" <> child_id,
],
),
),
[patch],
)
}
}
None -> {
let created =
ChildNode(
id: child_id,
identity: child_identity(runtime.parent_id, child_id),
lifecycle: Mounted,
mount_count: 1,
update_count: 0,
fingerprint: fingerprint,
)
let patch =
diff.Replace(target: child_target(runtime, child_id), html: html)
#(
Runtime(
..runtime,
children_rev: [created, ..runtime.children_rev],
patches_rev: append_ordered_to_rev(runtime.patches_rev, [patch]),
lifecycle_events_rev: append_ordered_to_rev(
runtime.lifecycle_events_rev,
[
"child_mounted:" <> child_id,
],
),
),
[patch],
)
}
}
}
/// Update one mounted child without remounting the parent.
pub fn update_child(
runtime: Runtime,
child_id: String,
html: String,
) -> #(Runtime, List(diff.Patch)) {
case child(runtime, child_id) {
Some(existing) ->
case existing.lifecycle {
Mounted -> {
let updated =
ChildNode(..existing, update_count: existing.update_count + 1)
let patch =
diff.UpdateSegments(
target: child_target(runtime, child_id),
fingerprint: updated.fingerprint,
dynamic_slots: [diff.slot("html", html)],
)
#(
Runtime(
..runtime,
children_rev: replace_child(
runtime.children_rev,
child_id,
updated,
),
patches_rev: append_ordered_to_rev(runtime.patches_rev, [patch]),
lifecycle_events_rev: append_ordered_to_rev(
runtime.lifecycle_events_rev,
[
"child_updated:" <> child_id,
],
),
),
[patch],
)
}
Suspended -> #(
Runtime(
..runtime,
authority_violations_rev: append_ordered_to_rev(
runtime.authority_violations_rev,
["update_blocked:suspended:" <> child_id],
),
),
[],
)
Failed(reason) -> #(
Runtime(
..runtime,
authority_violations_rev: append_ordered_to_rev(
runtime.authority_violations_rev,
["update_blocked:failed:" <> child_id <> ":" <> reason],
),
),
[],
)
Terminated -> #(
Runtime(
..runtime,
authority_violations_rev: append_ordered_to_rev(
runtime.authority_violations_rev,
["update_blocked:terminated:" <> child_id],
),
),
[],
)
}
None -> #(
Runtime(
..runtime,
authority_violations_rev: append_ordered_to_rev(
runtime.authority_violations_rev,
["update_blocked:unknown_child:" <> child_id],
),
),
[],
)
}
}
/// Suspend one child subtree.
pub fn suspend_child(runtime: Runtime, child_id: String) -> Runtime {
case child(runtime, child_id) {
Some(existing) ->
Runtime(
..runtime,
children_rev: replace_child(
runtime.children_rev,
child_id,
ChildNode(..existing, lifecycle: Suspended),
),
lifecycle_events_rev: append_ordered_to_rev(
runtime.lifecycle_events_rev,
[
"child_suspended:" <> child_id,
],
),
)
None -> runtime
}
}
/// Resume one suspended child subtree.
pub fn resume_child(runtime: Runtime, child_id: String) -> Runtime {
case child(runtime, child_id) {
Some(existing) ->
case existing.lifecycle {
Suspended ->
Runtime(
..runtime,
children_rev: replace_child(
runtime.children_rev,
child_id,
ChildNode(..existing, lifecycle: Mounted),
),
lifecycle_events_rev: append_ordered_to_rev(
runtime.lifecycle_events_rev,
[
"child_resumed:" <> child_id,
],
),
)
_ -> runtime
}
None -> runtime
}
}
/// Fail one child subtree while keeping siblings alive.
pub fn fail_child(
runtime: Runtime,
child_id: String,
reason: String,
) -> #(Runtime, List(diff.Patch)) {
case child(runtime, child_id) {
Some(existing) -> {
let failed = ChildNode(..existing, lifecycle: Failed(reason))
let patch =
diff.Replace(
target: child_target(runtime, child_id),
html: "<section data-child=\""
<> child_id
<> "\" data-state=\"failed\">"
<> reason
<> "</section>",
)
#(
Runtime(
..runtime,
children_rev: replace_child(runtime.children_rev, child_id, failed),
patches_rev: append_ordered_to_rev(runtime.patches_rev, [patch]),
lifecycle_events_rev: append_ordered_to_rev(
runtime.lifecycle_events_rev,
[
"child_failed:" <> child_id <> ":" <> reason,
],
),
),
[patch],
)
}
None -> #(
Runtime(
..runtime,
authority_violations_rev: append_ordered_to_rev(
runtime.authority_violations_rev,
["fail_blocked:unknown_child:" <> child_id],
),
),
[],
)
}
}
/// Terminate one child subtree.
pub fn terminate_child(
runtime: Runtime,
child_id: String,
) -> #(Runtime, List(diff.Patch)) {
case child(runtime, child_id) {
Some(existing) ->
case existing.lifecycle {
Terminated -> #(runtime, [])
_ -> {
let terminated = ChildNode(..existing, lifecycle: Terminated)
let patch = diff.Remove(target: child_target(runtime, child_id))
#(
Runtime(
..runtime,
children_rev: replace_child(
runtime.children_rev,
child_id,
terminated,
),
patches_rev: append_ordered_to_rev(runtime.patches_rev, [patch]),
lifecycle_events_rev: append_ordered_to_rev(
runtime.lifecycle_events_rev,
[
"child_terminated:" <> child_id,
],
),
),
[patch],
)
}
}
None -> #(
Runtime(
..runtime,
authority_violations_rev: append_ordered_to_rev(
runtime.authority_violations_rev,
["terminate_blocked:unknown_child:" <> child_id],
),
),
[],
)
}
}
/// Route one scoped event.
pub fn route_event(
runtime: Runtime,
scoped: ScopedEvent,
) -> #(Runtime, RouteDecision) {
case scoped.scope {
ParentScope -> #(
Runtime(
..runtime,
routed_events_rev: append_ordered_to_rev(runtime.routed_events_rev, [
"routed:parent:" <> scoped.name,
]),
),
RoutedToParent,
)
ChildScope(child_id) ->
case child(runtime, child_id) {
None ->
route_rejected(runtime, "unknown_child:" <> child_id, scoped.name)
Some(existing) ->
case existing.lifecycle {
Mounted -> #(
Runtime(
..runtime,
routed_events_rev: append_ordered_to_rev(
runtime.routed_events_rev,
["routed:child:" <> child_id <> ":" <> scoped.name],
),
),
RoutedToChild(child_id),
)
Suspended ->
route_rejected(
runtime,
"child_suspended:" <> child_id,
scoped.name,
)
Failed(reason) ->
route_rejected(
runtime,
"child_failed:" <> child_id <> ":" <> reason,
scoped.name,
)
Terminated ->
route_rejected(
runtime,
"child_terminated:" <> child_id,
scoped.name,
)
}
}
}
}
/// Parent id.
pub fn parent_id(runtime: Runtime) -> String {
runtime.parent_id
}
/// Parent route.
pub fn route(runtime: Runtime) -> String {
runtime.route
}
/// Parent patch target.
pub fn parent_target(runtime: Runtime) -> String {
runtime.parent_target
}
/// Child patch target.
pub fn child_target(runtime: Runtime, child_id: String) -> String {
runtime.parent_target <> "/child-" <> child_id
}
/// Parent mount count.
pub fn parent_mount_count(runtime: Runtime) -> Int {
runtime.parent_mount_count
}
/// Children in insertion order.
pub fn children(runtime: Runtime) -> List(ChildNode) {
list.reverse(runtime.children_rev)
}
/// One child by id.
pub fn child(runtime: Runtime, child_id: String) -> Option(ChildNode) {
find_child(runtime.children_rev, child_id)
}
/// All emitted patches in execution order.
pub fn patches(runtime: Runtime) -> List(diff.Patch) {
list.reverse(runtime.patches_rev)
}
/// Lifecycle events in execution order.
pub fn lifecycle_events(runtime: Runtime) -> List(String) {
list.reverse(runtime.lifecycle_events_rev)
}
/// Routed event labels in execution order.
pub fn routed_events(runtime: Runtime) -> List(String) {
list.reverse(runtime.routed_events_rev)
}
/// Authority violation labels in execution order.
pub fn authority_violations(runtime: Runtime) -> List(String) {
list.reverse(runtime.authority_violations_rev)
}
/// Child lifecycle label.
pub fn child_lifecycle_label(lifecycle: ChildLifecycle) -> String {
case lifecycle {
Mounted -> "mounted"
Suspended -> "suspended"
Failed(reason) -> "failed:" <> reason
Terminated -> "terminated"
}
}
/// Routing decision label.
pub fn route_decision_label(decision: RouteDecision) -> String {
case decision {
RoutedToParent -> "routed_to_parent"
RoutedToChild(child_id) -> "routed_to_child:" <> child_id
Rejected(reason) -> "rejected:" <> reason
}
}
/// Child signature.
pub fn child_signature(child: ChildNode) -> String {
child.id
<> ":"
<> child.identity
<> ":"
<> child_lifecycle_label(child.lifecycle)
<> ":mount="
<> int_to_string(child.mount_count)
<> ":update="
<> int_to_string(child.update_count)
<> ":fp="
<> child.fingerprint
}
/// Runtime signature.
pub fn runtime_signature(runtime: Runtime) -> String {
"parent="
<> runtime.parent_id
<> ":route="
<> runtime.route
<> ":mounts="
<> int_to_string(runtime.parent_mount_count)
<> "|children="
<> join_with(",", list.map(children(runtime), child_signature))
<> "|lifecycle="
<> join_with(",", lifecycle_events(runtime))
<> "|routed="
<> join_with(",", routed_events(runtime))
<> "|violations="
<> join_with(",", authority_violations(runtime))
}
/// True when child identity contracts are stable and unique.
pub fn stable_child_identity(runtime: Runtime) -> Bool {
let nodes = children(runtime)
identities_stable(nodes, runtime.parent_id) && identities_unique(nodes, [])
}
/// True when no authority invariants were violated.
pub fn authority_invariants(runtime: Runtime) -> Bool {
runtime.authority_violations_rev == []
}
/// True when one failed subtree does not corrupt siblings or parent mounts.
pub fn subtree_isolated(runtime: Runtime, failed_child_id: String) -> Bool {
case child(runtime, failed_child_id) {
Some(failed_child) ->
case failed_child.lifecycle {
Failed(_) ->
runtime.parent_mount_count == 1
&& siblings_not_failed(children(runtime), failed_child_id)
_ -> False
}
None -> False
}
}
fn route_rejected(
runtime: Runtime,
reason: String,
name: String,
) -> #(Runtime, RouteDecision) {
#(
Runtime(
..runtime,
routed_events_rev: append_ordered_to_rev(runtime.routed_events_rev, [
"rejected:" <> reason <> ":" <> name,
]),
),
Rejected(reason),
)
}
fn child_identity(parent_id: String, child_id: String) -> String {
parent_id <> "::" <> child_id
}
fn find_child(
children_rev: List(ChildNode),
child_id: String,
) -> Option(ChildNode) {
case children_rev {
[] -> None
[child, ..rest] ->
case child.id == child_id {
True -> Some(child)
False -> find_child(rest, child_id)
}
}
}
fn replace_child(
children_rev: List(ChildNode),
child_id: String,
replacement: ChildNode,
) -> List(ChildNode) {
case children_rev {
[] -> []
[child, ..rest] ->
case child.id == child_id {
True -> [replacement, ..rest]
False -> [child, ..replace_child(rest, child_id, replacement)]
}
}
}
fn siblings_not_failed(
nodes: List(ChildNode),
failed_child_id: String,
) -> Bool {
case nodes {
[] -> True
[node, ..rest] ->
case node.id == failed_child_id {
True -> siblings_not_failed(rest, failed_child_id)
False ->
case node.lifecycle {
Failed(_) -> False
_ -> siblings_not_failed(rest, failed_child_id)
}
}
}
}
fn identities_stable(nodes: List(ChildNode), parent_id: String) -> Bool {
case nodes {
[] -> True
[node, ..rest] ->
node.identity == child_identity(parent_id, node.id)
&& node.mount_count >= 1
&& node.update_count >= 0
&& identities_stable(rest, parent_id)
}
}
fn identities_unique(nodes: List(ChildNode), seen: List(String)) -> Bool {
case nodes {
[] -> True
[node, ..rest] ->
case contains(seen, node.identity) {
True -> False
False -> identities_unique(rest, [node.identity, ..seen])
}
}
}
fn append_ordered_to_rev(rev: List(a), ordered: List(a)) -> List(a) {
case ordered {
[] -> rev
[value, ..rest] -> append_ordered_to_rev([value, ..rev], rest)
}
}
fn contains(items: List(String), value: String) -> Bool {
case items {
[] -> False
[item, ..rest] ->
case item == value {
True -> True
False -> contains(rest, value)
}
}
}
fn join_with(separator: String, values: List(String)) -> String {
case values {
[] -> ""
[first, ..rest] ->
list.fold(rest, first, fn(accumulator, value) {
accumulator <> separator <> value
})
}
}
fn int_to_string(value: Int) -> String {
int.to_string(value)
}