Skip to main content

graphrefly_core/
handle.rs

1//! Identifier newtypes — the handle-protocol's core type vocabulary.
2//!
3//! Mirrors `~/src/graphrefly-ts/src/__experiments__/handle-core/core.ts:52–57`
4//! under Rust's stricter type discipline (CLAUDE.md Rust invariant 8 — no raw
5//! integers in public APIs).
6//!
7//! # Cleaving plane
8//!
9//! - [`NodeId`] — identifies a node in the graph. Allocated by the Core; opaque
10//!   to bindings.
11//! - [`HandleId`] — identifies a user value `T` in the binding-side registry.
12//!   The Core never sees `T`; equals-substitution under `equals: identity` is
13//!   a `u64` compare on `HandleId` (zero FFI). Custom equals crosses the
14//!   boundary explicitly via [`crate::boundary::BindingBoundary::custom_equals`].
15//! - [`FnId`] — identifies a user function (or a custom-equals oracle) in the
16//!   binding-side registry.
17//! - [`LockId`] — identifies a pause-lock. Multiple pausers can hold distinct
18//!   locks on the same node; the node remains paused until the lockset is
19//!   empty (R1.2.6, R2.6).
20//!
21//! All four are `u64` newtypes for cheap hashing, atomic increments, and
22//! lock-free version counters. They are intentionally NOT
23//! interconvertible — `NodeId(7)` and `HandleId(7)` are different things.
24
25/// Identifier for a node in the Core's dispatcher.
26#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)]
27pub struct NodeId(u64);
28
29impl NodeId {
30    /// Wrap a raw `u64` as a `NodeId`. Production code allocates via the Core's
31    /// id counter; this constructor exists for tests and for the binding-side
32    /// registry to round-trip ids through serialization.
33    #[must_use]
34    pub const fn new(raw: u64) -> Self {
35        Self(raw)
36    }
37
38    /// Unwrap to the underlying `u64`. Use for hashing, logging, or
39    /// FFI marshalling — never for arithmetic that mixes id spaces.
40    #[must_use]
41    pub const fn raw(self) -> u64 {
42        self.0
43    }
44}
45
46/// Identifier for a user value `T` in the binding-side registry.
47///
48/// The Core stores `HandleId` everywhere user values would otherwise sit:
49/// `cache`, `prevData`, `Data`/`Error` payloads, dep records. Equals-substitution
50/// under `EqualsMode::Identity` compares `HandleId` directly (a `u64` ==).
51#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)]
52pub struct HandleId(u64);
53
54impl HandleId {
55    /// Wrap a raw `u64`. Real handles come from the binding-side registry
56    /// (`bindings.ts` `valueRegistry.intern(value)`); this constructor is for
57    /// tests and FFI round-tripping.
58    #[must_use]
59    pub const fn new(raw: u64) -> Self {
60        Self(raw)
61    }
62
63    /// Unwrap to the underlying `u64`.
64    #[must_use]
65    pub const fn raw(self) -> u64 {
66        self.0
67    }
68
69    /// True if this is the [`NO_HANDLE`] sentinel (handle id 0 is never
70    /// assigned to a real value). Equivalent to `self == NO_HANDLE`.
71    #[must_use]
72    pub const fn is_sentinel(self) -> bool {
73        self.0 == NO_HANDLE.0
74    }
75}
76
77impl std::fmt::Display for HandleId {
78    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
79        write!(f, "HandleId({})", self.0)
80    }
81}
82
83/// Sentinel "no handle" — distinct from any valid handle (which start at 1).
84///
85/// Mirrors `core.ts:57` `NO_HANDLE = 0`. Used for:
86/// - `cache` of compute nodes that haven't fired yet.
87/// - `prevData[i]` for deps that haven't delivered DATA yet.
88/// - First-run gate condition (R2.5.3).
89///
90/// Per the handle-protocol cleaving plane, the binding-side registry refuses to
91/// intern `undefined`/`None` (the global SENTINEL per R1.2.4 / Lock 5.A);
92/// no real handle ever collides with this.
93pub const NO_HANDLE: HandleId = HandleId(0);
94
95/// Identifier for a user function (or a custom-equals oracle) in the
96/// binding-side registry.
97///
98/// The Core invokes user code by sending `(node_id, fn_id, dep_handles)` across
99/// the [`crate::boundary::BindingBoundary`]; the binding-side dereferences
100/// `fn_id` to a callable and `dep_handles` to user values, then registers the
101/// fn's output as a new handle and returns it.
102#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)]
103pub struct FnId(u64);
104
105impl FnId {
106    #[must_use]
107    pub const fn new(raw: u64) -> Self {
108        Self(raw)
109    }
110
111    #[must_use]
112    pub const fn raw(self) -> u64 {
113        self.0
114    }
115}
116
117/// Identifier for a pause-lock.
118///
119/// Per R1.2.6, `[PAUSE, lockId]` and `[RESUME, lockId]` MUST carry a lock id.
120/// Each node maintains a lockset (`HashSet<LockId>`); `paused` derives from
121/// `lockset.is_empty()`. Unknown-lockId `Resume` is a no-op (idempotent
122/// dispose).
123///
124/// # Allocation ranges (Slice F, A4 — 2026-05-07)
125///
126/// To prevent collision between user-supplied and dispatcher-allocated lock
127/// ids:
128///
129/// - `[0, 1<<32)` — **user range.** Direct callers of [`LockId::new`] (and
130///   the napi-rs binding's `u32 → LockId` marshalling) live here. Pick any
131///   value you like; the dispatcher will not allocate any id in this range.
132/// - `[1<<32, u64::MAX]` — **dispatcher range.** [`crate::Core::alloc_lock_id`]
133///   draws from this range, starting at `1<<32` and incrementing. Allocation
134///   is monotonic; no recycling.
135///
136/// Both constructors are public — the range convention is by construction at
137/// the dispatcher, not by visibility on the type. If a binding marshals lock
138/// ids beyond `u32::MAX` from user-facing input, raise the dispatcher floor
139/// (`CoreState::next_lock_id`) at construction time.
140#[derive(Copy, Clone, Eq, PartialEq, Hash, Debug, Ord, PartialOrd)]
141pub struct LockId(u64);
142
143impl LockId {
144    #[must_use]
145    pub const fn new(raw: u64) -> Self {
146        Self(raw)
147    }
148
149    #[must_use]
150    pub const fn raw(self) -> u64 {
151        self.0
152    }
153}
154
155#[cfg(test)]
156mod tests {
157    use super::*;
158
159    #[test]
160    fn ids_are_distinct_types() {
161        // Compile-time: these would not coexist if NodeId/HandleId/FnId/LockId
162        // were aliases. Run-time: just confirm the round-trip works.
163        let n = NodeId::new(42);
164        let h = HandleId::new(42);
165        let f = FnId::new(42);
166        let l = LockId::new(42);
167        assert_eq!(n.raw(), 42);
168        assert_eq!(h.raw(), 42);
169        assert_eq!(f.raw(), 42);
170        assert_eq!(l.raw(), 42);
171    }
172
173    #[test]
174    fn no_handle_is_sentinel() {
175        assert!(NO_HANDLE.is_sentinel());
176        assert_eq!(NO_HANDLE.raw(), 0);
177        assert!(!HandleId::new(1).is_sentinel());
178    }
179
180    #[test]
181    fn copy_eq_hash() {
182        use std::collections::HashSet;
183        // u64 newtypes round-trip through Copy + Eq + Hash for use in
184        // HashMap / HashSet / DashMap keys.
185        let a = HandleId::new(7);
186        let b = a;
187        assert_eq!(a, b);
188
189        let mut set = HashSet::new();
190        set.insert(NodeId::new(1));
191        set.insert(NodeId::new(2));
192        assert!(set.contains(&NodeId::new(1)));
193        assert!(!set.contains(&NodeId::new(99)));
194    }
195}